Deployment-Center-Integration im WinFormsHost

Loest die getrennten Server Watchdog (watchdog.mhdf.de) und LicenseLabrador
(license.mhdf.de) durch das Deployment Center (dc.mhdf.de) ab.

- DcConfig: einkompilierte Basis-URL und Produkt-Slug, Version aus BuildInfo
- DcApiClient: gemeinsamer HTTP-Zugang
- DcHeartbeatService: Heartbeat mit Metriken und DB-Health-Check
- DcErrorReporter/DcErrorSink: Error- und Fatal-Meldungen an den Fehler-Stream
- DcUpdateService: Update-Pruefung gegen den UpdateService
- LicenseGuard/LicenseDialog: Lizenzgate ueber /api/license/v1/validate,
  mit LicenseSession, Hardware-ID v2 und Unterscheidung transienter Fehler

WatchdogHeartbeatService entfernt, Betriebsdoku ersetzt.

Build: 0 Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-08 20:56:45 +02:00
co-authored by Claude Opus 5
parent aa19a89301
commit 168f4699e1
16 changed files with 1457 additions and 355 deletions
+113
View File
@@ -0,0 +1,113 @@
# Predictalytics
Betriebsdokumentation zur Anbindung an das Deployment Center (Lizenz, Watchdog,
Updates, Fehler-Stream): [`docs/BETRIEB-Deploymentcenter.md`](docs/BETRIEB-Deploymentcenter.md).
## Zentraler Bugtracker — Deployment Center
Erfasse unbehandelte Fehler, geplante Verbesserungen und Ideen im zentralen
Deployment Center. Basis-URL: `https://dc.mhdf.de`
### Zugang
Token steht in der Umgebungsvariable `DC_TOKEN`.
Header: `Authorization: Bearer $DC_TOKEN`
Ist `DC_TOKEN` nicht gesetzt, melde nichts und weise stattdessen darauf hin —
die Endpunkte antworten sonst mit `401 unauthorized`.
Die vollständige Schnittstellenbeschreibung liegt maschinenlesbar unter
`GET /api/openapi.json`, das Handbuch unter `/docs/`.
### Projekt
Dieses Projekt ist `predictalytics`.
Repo: `http://192.168.178.10:8418/Richard/Predictalytics`
Fällt dir ein Fehler im Deployment Center selbst auf, melde ihn unter
`deploymentcenter`.
### Etwas melden
`POST /api/bugtracker/v1/report`
```json
{
"project_slug": "predictalytics",
"type": "bug",
"title": "Kurze, aussagekräftige Zusammenfassung",
"description": "Unter welchen Bedingungen tritt es auf?",
"error_message": "Exakte Fehlermeldung",
"stack_trace": "Vollständiger Stacktrace",
"severity": "high",
"environment": "production",
"build_version": "v1.0.0",
"repo_url": "http://192.168.178.10:8418/Richard/Predictalytics",
"git_branch": "main",
"commit_sha": "a21536f",
"file_path": "src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs",
"line_no": 152,
"client_ref": "eindeutige-id-dieses-laufs"
}
```
**Setze immer `client_ref`** — ein wiederholter Aufruf mit demselben Wert legt
kein Duplikat an. Gib nach Möglichkeit `file_path` und `line_no` an; das spart
dem nächsten Agenten das Parsen des Stacktrace.
Schweregrade: `idea` (Gedanke für später), `wishlist` (Backlog),
`low`, `medium`, `high`, `critical`.
### Arbeit übernehmen
Bevor du an einem Item arbeitest, übernimm es — sonst arbeiten zwei Agenten
parallel am selben Problem:
```
POST /api/bugtracker/v1/manage?action=next
Body: {"project_slug": "predictalytics", "limit": 1}
```
Antwortet der Server mit `409 already_claimed`, nimm das nächste Item.
Die Reservierung läuft nach 30 Minuten ab; `action=claim` auf dieselbe ID
erneuert sie.
### Fortschritt festhalten
```
POST /api/bugtracker/v1/manage?action=comment&id=<ID>
Body: {"comment": "Was du herausgefunden hast", "action_taken": "investigated"}
```
`action_taken`: `investigated`, `fix_proposed`, `pr_opened`, `needs_human`,
`blocked`, `commented`.
### Abschließen
```
POST /api/bugtracker/v1/manage?action=resolve&id=<ID>
Body: {"resolved_in_build": "v1.0.1", "resolution_notes": "Was geändert wurde"}
```
Kommst du nicht weiter, gib das Item zurück statt es blockieren zu lassen:
```
POST /api/bugtracker/v1/manage?action=release&id=<ID>
Body: {"note": "Grund"}
```
### Laufzeitfehler
Die Anwendung meldet Error/Fatal selbst über `POST /api/errors/v1/report`
(`Services/DcErrorReporter.cs`) — dort nichts von Hand nachreichen.
### Release melden
Nach einem Release schließen sich Items mit passendem `resolved_in_build`
automatisch:
```
POST /api/updateservice/v1/publish
Body: {"product_slug": "predictalytics", "version": "1.0.1",
"download_url": "...", "sha256_hash": "...", "git_commit": "..."}
```
Die Version in `src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj`
(`<Version>`) muss dazu passen — sie ist es, die der Client als installierte
Version meldet.
### Fehlerbehandlung
Antworten haben die Form `{"status":"error","error":{"code":"…"}}`.
Reagiere auf `code`, nicht auf den Text:
- `401 unauthorized` — Token prüfen, nicht wiederholen
- `409 already_claimed` — nächstes Item nehmen
- `429 rate_limited` — Intervall verdoppeln, später erneut
+232
View File
@@ -0,0 +1,232 @@
# Betrieb: Deployment Center
Das **Deployment Center** (`https://dc.mhdf.de`, Schwester-Repo
`J:\Softwareprojekte\Deploymentcenter`) hat die beiden früheren Einzelserver abgelöst:
| Vorher | Jetzt |
|---|---|
| WatchDog auf `watchdog.mhdf.de`, `POST /api/heartbeat`, Header `X-Watchdog-Key` | Deployment Center, `POST /api/watchdog/v1/ping`, `Authorization: Bearer` |
| LicenseLabrador auf `license.mhdf.de`, Ed25519-signierte Envelopes | Deployment Center, `POST /api/license/v1/validate`, Hardware-ID v2 |
| — | UpdateService, Fehler-Stream und Bugtracker (neu) |
Die Anbindung sitzt im `Predictalytics.WinFormsHost` und referenziert das
C#-SDK `Deploymentcenter.Client` aus dem Schwester-Repo:
```
..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj
```
Das Repo muss also neben dem Predictalytics-Checkout liegen. `LicenseLabrador`
und `WatchDog` werden nicht mehr gebraucht.
---
## 0. Was einmalig zu tun ist
1. Im Deployment Center unter **Token-Verwaltung → Master-Token erstellen** ein
Token mit den Rechten **`watchdog:ping`** und **`bugtracker:report`** anlegen.
2. Im Host unter **Settings → Deployment Center → API Token** eintragen.
Der alte `X-Watchdog-Key` funktioniert nicht mehr — es ist ein anderer Server.
3. Beim ersten Start erscheint einmalig der Lizenzdialog: der Lizenzschlüssel muss
erneut eingegeben werden, weil die Aktivierung jetzt am Deployment Center hängt.
4. Serverseitig muss der **Evaluator-Cron** laufen — ohne ihn bleibt ein
abgestürzter Dienst dauerhaft grün:
```
* * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null
```
Der Token landet in der `settings.json` neben der Exe (nicht im Git).
---
## 1. Konfiguration (PropertyGrid, Kategorie „Deployment Center")
| Feld | Default | Bedeutung |
|---|---|---|
| `Server URL` | `https://dc.mhdf.de` | **schreibgeschützt**, siehe unten |
| `API Token` | *(leer)* | `Authorization: Bearer`. Ohne Token: keine Heartbeats, keine Fehlermeldungen. |
| `Heartbeat aktiv` | `true` | Dead-Man's-Switch an/aus |
| `Monitor Source` | `Predictalytics` | Monitor-Name im Dashboard (Auto-Registrierung beim ersten Ping) |
| `Monitor Instance` | `default` | falls mehrere Instanzen laufen |
| `Heartbeat-Intervall (Sekunden)` | `60` | `warning` nach 2×, `down` nach 4× |
| `Fehler melden` | `true` | Error/Fatal an den Fehler-Stream |
| `Update-Prüfung beim Start` | `true` | prüft nur, installiert nichts von selbst |
| `Update-Kanal` | `prod` | `prod`, `beta` oder `dev` |
**Warum ist die Server-URL nicht einstellbar?** Sie entscheidet, wohin die
Lizenzprüfung geht und woher Update-Pakete kommen. Ein einstellbarer Endpoint
würde erlauben, die App auf einen gefälschten Lizenz- oder Update-Server zu
zeigen. Sie steht als Konstante in `Services/DcConfig.cs`.
---
## 2. Watchdog (Dead-Man's-Switch)
`Services/DcHeartbeatService.cs` sendet alle *n* Sekunden einen
`POST /api/watchdog/v1/ping`. Beim regulären Schließen geht ein Ping mit
`status: "stopped"` raus — der Evaluator lässt einen so beendeten Monitor in
Ruhe, bis wieder ein normaler Heartbeat eintrifft. Ohne das folgt wenige Minuten
nach jedem geordneten Beenden ein Fehlalarm.
**Ein Ausfall des Deployment Centers darf Predictalytics nie beeinträchtigen.**
Alle Aufrufe sind best effort; der erste Fehlschlag wird als Warnung geloggt,
Folgefehler nur noch auf Debug-Level (keine Log-Flut). Wird das Token abgelehnt
(`401`/`403`), stellt der Dienst die Versuche ganz ein — Wiederholen kann daran
nichts ändern.
### Mitgesendeter Gesundheitszustand
Ein Heartbeat beweist nur, dass ein Timer läuft. Deshalb schickt die App ihren
selbst ermittelten Zustand mit:
| `checks` | Bedeutung |
|---|---|
| `db` | `SELECT 1` gegen die konfigurierte MySQL, gedeckelt auf 5 s. Schlägt sie fehl, stuft der Server den Heartbeat auf `warning`. |
| `metrics` | Bedeutung |
|---|---|
| `uptime_sec` | Laufzeit des Prozesses |
| `workers_running` | 0/1 — Hintergrund-Worker gestartet |
| `webserver_running` | 0/1 — eingebetteter Kestrel gestartet |
| `memory_mb` | verwalteter Heap |
| `db_size_mb` | zuletzt ermittelte Datenbankgröße |
Zusätzlich geht seit Server 2.1 das Feld `version` mit — im Dashboard ist damit
sichtbar, welcher Build läuft, und ein Ausfall lässt sich einem Rollout zuordnen.
Gestoppte Worker sind **bewusst kein** fehlgeschlagener Check: das ist ein
gewollter Betriebszustand und würde den Monitor sonst dauerhaft auf `warning`
halten. Der Zustand steckt als Metrik drin und lässt sich dort auswerten.
Metriken werden serverseitig 14 Tage lang mit Verlauf gehalten
(`GET /api/watchdog/v1/metrics?source=Predictalytics`).
### Maschine mit überwachen
Der Heartbeat deckt nur *diesen* Prozess ab. Für die Maschine selbst gehört
zusätzlich ein OS-Agent auf den Host (siehe
`Deploymentcenter/docs/WATCHDOG_INTEGRATION_GUIDE.md`). Läuft beides, sollte im
WebUI unter **WatchDog → System-Hierarchie** die *Übergeordnete Entität* des
Predictalytics-Monitors auf den Host gesetzt werden — dann erzeugt ein
Maschinenausfall eine Meldung statt zwei.
---
## 3. Lizenzierung
`Services/LicenseGuard.cs` prüft beim Start, ob eine nutzbare Lizenz vorliegt;
`Program.Main` bricht sonst ab, bevor die MainForm entsteht. Ohne gültige Lizenz
erscheint `Services/LicenseDialog.cs` zur Key-Eingabe.
- **Produkt-Slug:** `predictalytics`
- **Hardware-ID v2:** `2:win:<sha256>` aus `HKLM\...\Cryptography\MachineGuid`.
Der Rechnername steckt **nicht** im Hash — Umbenennen kostet keinen
Aktivierungsplatz.
- **Offline-Cache:** AES-256-GCM + DPAPI unter `%AppData%\predictalytics\license\state.dat`
(Schema 3 seit SDK 2.1, Schema 2 wird noch gelesen). Dort liegt auch der
Lizenzschlüssel — `LicenseClient.TryGetCachedKey()` holt ihn beim Start, damit
ohne Dialog revalidiert werden kann.
- **Revalidierung zur Laufzeit:** alle 12 h.
- **Gemeldete Version:** `LicenseClient.DefaultAppVersion` wird in `Program.Main`
auf `BuildInfo.Version` gesetzt; ohne das trüge jede Installation in der
Aktivierungsliste dieselbe „1.0.0".
### Offline-Gnadenfrist
Seit Server 2.1 ist sie **echt begrenzt**: der Server liefert `cache_ttl_hours`
(je Projekt im WebUI, Standard 168 h = 7 Tage). Vorher galt faktisch das
Lizenz-Ablaufdatum, bei einer Lizenz bis 2040 also unbegrenzt.
Läuft die Frist in weniger als 48 h ab, warnt der Host beim Start und bei jeder
Revalidierung. Die verbleibende Frist zeigt **Deployment Center → Lizenzstatus
anzeigen**. Falls Predictalytics planmäßig länger offline laufen soll, muss die
TTL im WebUI für das Projekt `predictalytics` heraufgesetzt werden.
### Wann die App sich beendet — und wann nicht
Maßgeblich ist `LicenseValidationResult.IsTransient`:
| | Zustände | Reaktion |
|---|---|---|
| `IsValid` | `valid`, `valid_offline` | weiter |
| `IsTransient` | `server_unavailable`, `cache_expired` | **kein Urteil, nur eine gescheiterte Verbindung** — weiterlaufen, beim nächsten Durchlauf erneut versuchen |
| sonst | `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `clock_rollback` | Meldung und Beenden |
`cache_expired` beendet die laufende Sitzung also nicht — der **nächste Start**
bleibt aber am Lizenzdialog hängen, weil `IsValid` dann falsch ist. Der Host
loggt diesen Fall deshalb als Error, nicht bloß als Warnung.
### Deaktivierung bei PC-Wechsel
`POST /api/license/v1/deactivate` verlangt den `shared_key` aus der
Server-Konfiguration. Der gehört **nicht** in die ausgelieferte Anwendung,
deshalb ist die Funktion hier nicht verdrahtet. Übliche Route: im WebUI unter
**Lizenzen → Hardware-Liste → „Freigeben"**. Die Hardware-ID zeigt der Host unter
**Deployment Center → Lizenzstatus anzeigen**.
---
## 4. Fehler-Stream
`Services/DcErrorReporter.cs` meldet Laufzeitfehler an
`POST /api/errors/v1/report`. Zwei Wege führen dorthin:
1. **Globale Handler** — `Application.ThreadException` (UI-Thread),
`AppDomain.UnhandledException` (Level `fatal`, wird blockierend gesendet, weil
der Prozess gleich weg ist) und `TaskScheduler.UnobservedTaskException`.
2. **Serilog-Sink** (`Services/DcErrorSink.cs`) — jedes `Log.Error`/`Log.Fatal`,
auch aus Worker und Kestrel.
Lokale Bremse, damit das Rate-Limit (60/min und IP) nicht verbrannt wird:
höchstens 20 Meldungen pro Minute, derselbe Fehler höchstens alle 5 Minuten.
Serverseitig werden gleiche Fehler ohnehin gruppiert und hochgezählt;
betriebsbedingtes Rauschen gehört in die **Ignore-Regeln** des WebUI — ein
Treffer zählt weiter, meldet aber nicht.
> Der Serilog-Filter in `Program.cs` wirft `Duplicate entry`-Ausnahmen schon
> vorher weg; die erreichen den Stream also gar nicht.
---
## 5. Updates
`Services/DcUpdateService.cs` fragt beim Start
`GET /api/updateservice/v1/check` ab (ohne Token). Gefunden wird nur — installiert
wird nichts von selbst:
- Kein Update: Info ins Log.
- Update verfügbar: Warnung ins Log, Hinweis in der Statusleiste.
- **Kritisches** Update: zusätzlich ein Dialog.
- Menü **Deployment Center → Nach Updates suchen** prüft jederzeit von Hand.
Liegt `update-agent.exe` neben der Anwendung, bietet der Dialog an, ihn zu
starten; die Anwendung meldet dann vorher `stopped` und beendet sich. Fehlt der
Agent, weist der Dialog auf die manuelle Installation hin.
Seit SDK 2.1 liefert auch der API-Zweig vollständige Release-Daten
(Download-Adresse, Prüfsumme, Changelog, Kritikalität) — der Dialog zeigt die
Release Notes also auch dann, wenn die statische `latest.json` fehlt.
### Versionsstand
`Predictalytics.WinFormsHost.csproj` importiert `Deploymentcenter.BuildInfo.targets`
und erzeugt daraus zur Übersetzungszeit `Predictalytics.WinFormsHost.BuildInfo`
mit `Version`, `GitCommit`, `GitCommitShort`, `BuildDateUtc`, `Channel` und
`Summary`. Quelle ist `<Version>` in derselben csproj — beim Release dort
hochziehen und mit
```
pack-and-deploy --project predictalytics --version <x> --channel prod
```
veröffentlichen. Dieselbe Version geht an die Lizenz-Aktivierungsliste, an den
Heartbeat und als `build` an den Fehler-Stream; `GitCommitShort` reist im
`context` jeder Fehlermeldung mit.
---
## 6. Bugtracker
Der Agenten-Workflow ist in [`../CLAUDE.md`](../CLAUDE.md) beschrieben.
Projekt-Slug: `predictalytics`. Items, deren `resolved_in_build` einer
veröffentlichten Version entspricht, schließen sich beim Publish von selbst.
-82
View File
@@ -1,82 +0,0 @@
# Betrieb: Watchdog-Überwachung & LicenseLabrador-Lizenzierung
Beide Integrationen sitzen im `Predictalytics.WinFormsHost` (dem Produktiv-Host) und
binden zwei eigenständige Schwester-Projekte an:
| Projekt | Pfad | Rolle |
|---|---|---|
| Watchdog | `J:\Softwareprojekte\WatchDog` | PHP/MySQL-Server auf `watchdog.mhdf.de`, empfängt Heartbeats |
| LicenseLabrador | `J:\Softwareprojekte\LicenseLabrador` | PHP-Lizenzserver auf `license.mhdf.de` + C#-SDK |
---
## 1. Watchdog (Dead-Man's-Switch)
`Services/WatchdogHeartbeatService.cs` sendet alle *n* Sekunden einen
`POST /api/heartbeat` an den Watchdog. Bleiben die Heartbeats aus — weil die App
abgestürzt ist oder die ganze Maschine weg ist — schlägt der Watchdog Alarm.
Beim regulären Schließen geht ein `POST /api/event` mit `kind=stopped_graceful`
raus, damit ein geplantes Beenden nicht als Crash alarmiert wird.
> **Nicht auf `kind=stopping` ändern.** Der Watchdog akzeptiert den Wert im
> Router, aber `event_log.kind` ist ein ENUM ohne `stopping`. Der Server setzt
> dann zwar noch den Zustand auf `stopped`, scheitert aber am Insert und liefert
> HTTP 500 — das Event fehlt in der Historie. Verifiziert am 2026-07-30.
**Wichtig:** Ein Ausfall des Watchdogs darf Predictalytics nie beeinträchtigen.
Alle Aufrufe sind best effort; der erste Fehlschlag wird als Warnung geloggt,
Folgefehler nur noch auf Debug-Level (keine Log-Flut).
### Konfiguration (PropertyGrid im Host, Kategorie „Watchdog")
| Feld | Default | Bedeutung |
|---|---|---|
| `Enabled` | `true` | Heartbeats an/aus |
| `Server URL` | `https://watchdog.mhdf.de` | Basis-URL |
| `API Key` | *(leer)* | `X-Watchdog-Key` — Shared Key **oder** Agent-Token. Ohne Key passiert nichts. |
| `Source` | `Predictalytics` | Monitor-Name im Dashboard (Auto-Registrierung beim ersten Beat) |
| `Instance` | `default` | falls mehrere Instanzen laufen |
| `Interval (Sekunden)` | `60` | Sende-Takt; Alarm nach ca. `Intervall × 1,5 + 30 s` |
Der Key landet in der `settings.json` neben der Exe (nicht im Git).
### PolyTrader-Maschine mit überwachen
Der Heartbeat aus Predictalytics deckt nur *diesen* Prozess ab. Damit auch die
Maschine überwacht wird, auf der PolyTrader läuft, gehört dort zusätzlich der
OS-Agent hin: `WatchDog\agents\windows\watchdog-agent.ps1` als Scheduled Task
(inkl. Shutdown-Hook), bzw. `agents/linux/watchdog-agent.sh` per systemd-Timer.
---
## 2. LicenseLabrador (Kopierschutz)
`Services/LicenseGuard.cs` prüft beim Start, ob eine nutzbare Lizenz vorliegt
(`Program.Main` bricht sonst ab, bevor die MainForm überhaupt entsteht).
Ohne gültige Lizenz erscheint `Services/LicenseDialog.cs` zur Key-Eingabe.
Produkt-Slug, Endpoint und der Ed25519-Public-Key sind **bewusst einkompiliert**
und nicht konfigurierbar — ein einstellbarer Endpoint würde erlauben, die App auf
einen gefälschten Lizenzserver zu zeigen.
- **Produkt-Slug:** `predictalytics`
- **Offline-Gnadenfrist:** 168 h (7 Tage) — danach ist Serverkontakt nötig
- **Revalidierung zur Laufzeit:** alle 12 h; bei Widerruf/Ablauf beendet sich die App
- **Härtung:** `VerifyChecksum` (HMAC über State + Key + Hardware-ID) gegen Memory-Patches;
Nonce-Reflexion und Signaturprüfung übernimmt das SDK
### Status
Produkt `predictalytics` und Lizenzschlüssel sind angelegt; die Aktivierung wurde
am 2026-07-30 gegen den Produktivserver verifiziert (`Valid`, gültig bis
2040-12-31, Checksum-Prüfung bestanden). Der einkompilierte Public Key passt zum
`signing.pub` des Servers — sonst käme `TamperSuspected` statt `Valid`.
Die Aktivierung ist an die Hardware-ID der jeweiligen Maschine gebunden. Auf
einem neuen Rechner erscheint einmalig der Dialog; der Cache liegt danach unter
`%AppData%\predictalytics\license`.
### Deaktivierung bei PC-Wechsel
`LicenseClient.DeactivateAsync()` gibt die Aktivierung wieder frei. Aktuell nicht
in der UI verdrahtet — bei Bedarf als Menüpunkt ergänzen.
+44 -24
View File
@@ -1,5 +1,6 @@
using System.ComponentModel; using System.ComponentModel;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
namespace Predictalytics.WinFormsHost; namespace Predictalytics.WinFormsHost;
@@ -27,41 +28,60 @@ public class AppSettings
[Editor("System.ComponentModel.Design.MultilineStringEditor, System.Design, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof(System.Drawing.Design.UITypeEditor))] [Editor("System.ComponentModel.Design.MultilineStringEditor, System.Design, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof(System.Drawing.Design.UITypeEditor))]
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; } public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
[Category("Watchdog")] [Category("Deployment Center")]
[DisplayName("Enabled")]
[Description("Sendet periodische Heartbeats an den externen Watchdog-Server (Dead-Man's-Switch). Benötigt einen API Key.")]
[DefaultValue(true)]
public bool WatchdogEnabled { get; set; } = true;
[Category("Watchdog")]
[DisplayName("Server URL")] [DisplayName("Server URL")]
[Description("Basis-URL des Watchdog-Servers.")] [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.")]
[DefaultValue("https://watchdog.mhdf.de")] [ReadOnly(true)]
public string WatchdogUrl { get; set; } = "https://watchdog.mhdf.de"; [JsonIgnore]
public string DcServerUrl => Services.DcConfig.BaseUrl;
[Category("Watchdog")] [Category("Deployment Center")]
[DisplayName("API Key")] [DisplayName("API Token")]
[Description("Shared Key oder Agent-Token des Watchdog-Servers (X-Watchdog-Key). Ohne Key werden keine Heartbeats gesendet.")] [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)] [PasswordPropertyText(true)]
public string WatchdogApiKey { get; set; } = ""; public string DcToken { get; set; } = "";
[Category("Watchdog")] [Category("Deployment Center")]
[DisplayName("Source")] [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.")] [Description("Eindeutiger Monitor-Name dieses Dienstes im Watchdog-Dashboard.")]
[DefaultValue("Predictalytics")] [DefaultValue("Predictalytics")]
public string WatchdogSource { get; set; } = "Predictalytics"; public string DcSource { get; set; } = "Predictalytics";
[Category("Watchdog")] [Category("Deployment Center")]
[DisplayName("Instance")] [DisplayName("Monitor Instance")]
[Description("Instanz-Kennung, falls mehrere Predictalytics-Instanzen laufen.")] [Description("Instanz-Kennung, falls mehrere Predictalytics-Instanzen laufen.")]
[DefaultValue("default")] [DefaultValue("default")]
public string WatchdogInstance { get; set; } = "default"; public string DcInstance { get; set; } = "default";
[Category("Watchdog")] [Category("Deployment Center")]
[DisplayName("Interval (Sekunden)")] [DisplayName("Heartbeat-Intervall (Sekunden)")]
[Description("Sende-Takt der Heartbeats. Der Watchdog alarmiert, wenn ~1,5× dieses Intervall + 30 s ohne Heartbeat vergehen.")] [Description("Sende-Takt der Heartbeats. Der Evaluator stuft nach dem Doppelten auf 'warning' und nach dem Vierfachen auf 'down'.")]
[DefaultValue(60)] [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";
private string _dbServer = "localhost"; private string _dbServer = "localhost";
private string _dbName = ""; private string _dbName = "";
+191 -28
View File
@@ -11,7 +11,8 @@ public partial class MainForm : Form
private bool _workerRunning; private bool _workerRunning;
private bool _webServerRunning; private bool _webServerRunning;
private AppSettings _settings = null!; private AppSettings _settings = null!;
private WatchdogHeartbeatService? _watchdog; private DcHeartbeatService? _heartbeat;
private double? _lastDbSizeMb;
/// <summary>Exposes the terminal RichTextBox for the Serilog sink.</summary> /// <summary>Exposes the terminal RichTextBox for the Serilog sink.</summary>
public RichTextBox Terminal => rtb_terminal; public RichTextBox Terminal => rtb_terminal;
@@ -29,9 +30,9 @@ public partial class MainForm : Form
/// <summary> /// <summary>
/// Called after Serilog is configured. Initializes the embedded web server. /// Called after Serilog is configured. Initializes the embedded web server.
/// </summary> /// </summary>
public void Initialize() public void Initialize(AppSettings settings)
{ {
_settings = AppSettings.Load(); _settings = settings;
pg_settings.SelectedObject = _settings; pg_settings.SelectedObject = _settings;
pg_settings.PropertyValueChanged += (s, e) => { pg_settings.PropertyValueChanged += (s, e) => {
_settings.Save(); _settings.Save();
@@ -41,7 +42,8 @@ public partial class MainForm : Form
_webServer.DbConnectionDebug = _settings.DbConnectionDebug; _webServer.DbConnectionDebug = _settings.DbConnectionDebug;
_webServer.EgressChannelsText = _settings.EgressChannelsText; _webServer.EgressChannelsText = _settings.EgressChannelsText;
} }
RestartWatchdog(); DcErrorReporter.Configure(_settings.DcToken, _settings.DcErrorReportingEnabled);
RestartHeartbeat();
}; };
_webServer = new EmbeddedWebServer(); _webServer = new EmbeddedWebServer();
@@ -52,11 +54,12 @@ public partial class MainForm : Form
// Build Version (Date of compilation/file creation) // Build Version (Date of compilation/file creation)
try { try {
var buildDate = new FileInfo(this.GetType().Assembly.Location).LastWriteTime; var buildDate = new FileInfo(this.GetType().Assembly.Location).LastWriteTime;
label_buildVersion.Text = $"Build: {buildDate:yyyy-MM-dd HH:mm:ss}"; label_buildVersion.Text = $"v{DcConfig.AppVersion} — Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
} catch { } catch {
label_buildVersion.Text = "Build: Unknown"; label_buildVersion.Text = $"v{DcConfig.AppVersion}";
} }
BuildDeploymentcenterMenu();
UpdateStatusBar(); UpdateStatusBar();
// Wire up button events // Wire up button events
@@ -72,38 +75,197 @@ public partial class MainForm : Form
dbSizeTimer.Tick += async (s, e) => await UpdateDbSizeAsync(); dbSizeTimer.Tick += async (s, e) => await UpdateDbSizeAsync();
dbSizeTimer.Start(); dbSizeTimer.Start();
RestartWatchdog(); RestartHeartbeat();
if (_settings.DcUpdateCheckEnabled)
{
_ = CheckForUpdatesAsync(silent: true);
}
} }
/// <summary> /// <summary>
/// (Re-)creates the Watchdog heartbeat sender from the current settings. /// (Re-)creates the Deployment Center heartbeat sender from the current settings.
/// Called at startup and whenever settings change. /// Called at startup and whenever settings change.
/// </summary> /// </summary>
private void RestartWatchdog() private void RestartHeartbeat()
{ {
_watchdog?.Dispose(); _heartbeat?.Dispose();
_watchdog = null; _heartbeat = null;
if (!_settings.WatchdogEnabled) return; if (!_settings.DcHeartbeatEnabled) return;
if (string.IsNullOrWhiteSpace(_settings.WatchdogApiKey) || string.IsNullOrWhiteSpace(_settings.WatchdogUrl)) if (string.IsNullOrWhiteSpace(_settings.DcToken))
{ {
Log.Information("🐕 Watchdog ist aktiviert, aber URL/API Key fehlen — bitte in den Settings eintragen."); Log.Information("🐕 Heartbeat ist aktiviert, aber es fehlt das Deployment-Center-Token — bitte in den Settings eintragen.");
return; return;
} }
_watchdog = new WatchdogHeartbeatService( _heartbeat = new DcHeartbeatService(
_settings.WatchdogUrl, _settings.DcToken,
_settings.WatchdogApiKey, _settings.DcSource,
_settings.WatchdogSource, _settings.DcInstance,
_settings.WatchdogInstance, _settings.DcHeartbeatIntervalSeconds,
_settings.WatchdogIntervalSeconds, CollectHeartbeatSnapshotAsync);
metadataProvider: () => new _heartbeat.Start();
}
/// <summary>
/// Assembles what this app knows about its own health. A heartbeat alone only proves that
/// a timer runs — the DB check is what shows whether the app can actually do its work.
/// </summary>
private async Task<DcHeartbeatSnapshot> CollectHeartbeatSnapshotAsync(CancellationToken ct)
{ {
workersRunning = _workerRunning, var snapshot = new DcHeartbeatSnapshot
webserverRunning = _webServerRunning {
}); Message = _workerRunning ? "Worker laufen" : "Worker gestoppt"
_watchdog.Start(); };
// 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"] = _workerRunning ? 1 : 0;
snapshot.Metrics["webserver_running"] = _webServerRunning ? 1 : 0;
snapshot.Metrics["memory_mb"] = Math.Round(GC.GetTotalMemory(forceFullCollection: false) / 1024d / 1024d, 1);
if (_lastDbSizeMb is { } dbSize) snapshot.Metrics["db_size_mb"] = Math.Round(dbSize, 2);
var db = await ProbeDatabaseAsync(ct);
if (db is not null) snapshot.Checks["db"] = db;
return snapshot;
}
/// <summary>SELECT 1 against the configured MySQL, capped so it cannot stall the heartbeat.</summary>
private async Task<DcCheck?> ProbeDatabaseAsync(CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_settings.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(_settings.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);
}
}
/// <summary>Adds the Deployment Center entries to the menu bar (not part of the designer).</summary>
private void BuildDeploymentcenterMenu()
{
var menu = new ToolStripMenuItem("Deployment Center");
var updateItem = new ToolStripMenuItem("Nach Updates suchen");
updateItem.Click += async (_, _) => await CheckForUpdatesAsync(silent: false);
var licenseItem = new ToolStripMenuItem("Lizenzstatus anzeigen");
licenseItem.Click += (_, _) => ShowLicenseStatus();
menu.DropDownItems.Add(updateItem);
menu.DropDownItems.Add(licenseItem);
menuStrip1.Items.Add(menu);
}
/// <summary>
/// Asks the UpdateService for a newer release. Silent at startup (log + status bar);
/// only a critical release interrupts the user.
/// </summary>
private async Task CheckForUpdatesAsync(bool silent)
{
try
{
var result = await DcUpdateService.CheckAsync(_settings.DcUpdateChannel);
if (result.Error is not null)
{
Log.Warning("Update-Prüfung fehlgeschlagen: {Message}", result.Message);
if (!silent)
{
MessageBox.Show($"Update-Prüfung fehlgeschlagen:\n{result.Message}",
"Deployment Center", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
return;
}
if (!result.UpdateAvailable)
{
Log.Information("Update-Prüfung: v{Version} ist aktuell (Kanal {Channel}).",
DcConfig.AppVersion, _settings.DcUpdateChannel);
if (!silent)
{
MessageBox.Show($"Predictalytics v{DcConfig.AppVersion} ist aktuell.",
"Deployment Center", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
return;
}
var latest = result.LatestRelease?.Version ?? "?";
Log.Warning("⬆ Update verfügbar: v{Latest} (installiert: v{Current}, Kanal {Channel}){Critical}",
latest, DcConfig.AppVersion, _settings.DcUpdateChannel, result.IsCritical ? " — KRITISCH" : "");
label_buildVersion.Text = $"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)
{
MessageBox.Show(text, "Deployment Center", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (MessageBox.Show(text, "Deployment Center", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes)
{
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(_settings.DcUpdateChannel);
}
catch (Exception ex)
{
Log.Warning(ex, "Update-Prüfung fehlgeschlagen");
}
}
private void ShowLicenseStatus()
{
var hardware = LicenseGuard.GetHardwareInfo();
var result = Program.LicenseSession?.LastResult;
var grace = result?.CacheExpiresAt is { } expiresAt && expiresAt > 0
? $"\nOffline-Gnadenfrist bis: {DateTimeOffset.FromUnixTimeSeconds(expiresAt).UtcDateTime:yyyy-MM-dd HH:mm} UTC"
: "";
MessageBox.Show(
$"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}\n\n" +
"Aktivierung für diesen Rechner freigeben: im Deployment Center unter " +
"Lizenzen → Hardware-Liste → „Freigeben\".",
"Lizenzstatus", MessageBoxButtons.OK, MessageBoxIcon.Information);
} }
private async void Btn_serverstart_Click(object? sender, EventArgs e) private async void Btn_serverstart_Click(object? sender, EventArgs e)
@@ -177,9 +339,9 @@ public partial class MainForm : Form
protected override void OnFormClosing(FormClosingEventArgs e) protected override void OnFormClosing(FormClosingEventArgs e)
{ {
_watchdog?.NotifyStopping(); _heartbeat?.NotifyStopping();
_watchdog?.Dispose(); _heartbeat?.Dispose();
_watchdog = null; _heartbeat = null;
_workerCts?.Cancel(); _workerCts?.Cancel();
_webServer?.StopWebServerAsync().GetAwaiter().GetResult(); _webServer?.StopWebServerAsync().GetAwaiter().GetResult();
base.OnFormClosing(e); base.OnFormClosing(e);
@@ -332,6 +494,7 @@ public partial class MainForm : Form
if (result != DBNull.Value && result != null) if (result != DBNull.Value && result != null)
{ {
var sizeMb = Convert.ToDouble(result); var sizeMb = Convert.ToDouble(result);
_lastDbSizeMb = sizeMb;
this.Invoke(() => label_dbSize.Text = $"DB Size: {sizeMb:F2} MB"); this.Invoke(() => label_dbSize.Text = $"DB Size: {sizeMb:F2} MB");
} }
} }
@@ -9,6 +9,8 @@
<ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode> <ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode>
<ApplicationVisualStyles>true</ApplicationVisualStyles> <ApplicationVisualStyles>true</ApplicationVisualStyles>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<!-- Wird als current_version an den UpdateService und als build an den Fehler-Stream gemeldet. -->
<Version>1.0.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -31,10 +33,15 @@
<ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" /> <ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" />
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" /> <ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" /> <ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
<!-- Externes Schwester-Repo: J:\Softwareprojekte\LicenseLabrador muss neben dem Predictalytics-Checkout liegen. --> <!-- Externes Schwester-Repo: J:\Softwareprojekte\Deploymentcenter muss neben dem Predictalytics-Checkout liegen.
<ProjectReference Include="..\..\..\..\LicenseLabrador\client-dotnet\LicenseLabrador.Client\LicenseLabrador.Client.csproj" /> Löst Watchdog + LicenseLabrador ab (Lizenz, UpdateService, Fehler-Stream, Bugtracker). -->
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
</ItemGroup> </ItemGroup>
<!-- Erzeugt Predictalytics.WinFormsHost.BuildInfo (Version, Git-Commit, Build-Datum, Kanal)
zur Übersetzungszeit aus <Version> und dem Git-Stand. -->
<Import Project="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
<Target Name="CleanupLocalization" AfterTargets="Build"> <Target Name="CleanupLocalization" AfterTargets="Build">
<ItemGroup> <ItemGroup>
<LanguageFolders Include="$(TargetDir)cs;$(TargetDir)de;$(TargetDir)es;$(TargetDir)fr;$(TargetDir)it;$(TargetDir)ja;$(TargetDir)ko;$(TargetDir)pl;$(TargetDir)pt-BR;$(TargetDir)ru;$(TargetDir)tr;$(TargetDir)zh-Hans;$(TargetDir)zh-Hant" /> <LanguageFolders Include="$(TargetDir)cs;$(TargetDir)de;$(TargetDir)es;$(TargetDir)fr;$(TargetDir)it;$(TargetDir)ja;$(TargetDir)ko;$(TargetDir)pl;$(TargetDir)pt-BR;$(TargetDir)ru;$(TargetDir)tr;$(TargetDir)zh-Hans;$(TargetDir)zh-Hant" />
+30 -9
View File
@@ -7,17 +7,17 @@ namespace Predictalytics.WinFormsHost;
internal static class Program internal static class Program
{ {
/// <summary>The license this run is based on — read by the "Lizenzstatus" menu entry.</summary>
internal static LicenseSession? LicenseSession { get; private set; }
[STAThread] [STAThread]
static void Main() static void Main()
{ {
ApplicationConfiguration.Initialize(); ApplicationConfiguration.Initialize();
// ─── License gate: no usable license, no app ─── // Settings first: the error reporting needs the token before the license gate runs,
var licenseClient = LicenseGuard.EnsureLicensed(); // otherwise a failing activation would never show up in the error stream.
if (licenseClient == null) var settings = AppSettings.Load();
{
return;
}
var mainForm = new MainForm(); var mainForm = new MainForm();
var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm); var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm);
@@ -48,6 +48,9 @@ internal static class Program
// ── RichTextBox Terminal ── // ── RichTextBox Terminal ──
.WriteTo.Sink(new RichTextBoxSink(rtbWriteAction), restrictedToMinimumLevel: LogEventLevel.Warning) .WriteTo.Sink(new RichTextBoxSink(rtbWriteAction), restrictedToMinimumLevel: LogEventLevel.Warning)
// ── Deployment Center error stream (Error/Fatal) ──
.WriteTo.Sink(new DcErrorSink(), restrictedToMinimumLevel: LogEventLevel.Error)
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// FILE SINKS — By Level // FILE SINKS — By Level
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
@@ -147,19 +150,37 @@ internal static class Program
.CreateLogger(); .CreateLogger();
// ─── Deployment Center: error stream + global exception handlers ───
DcErrorReporter.Configure(settings.DcToken, settings.DcErrorReportingEnabled);
DcErrorReporter.InstallGlobalHandlers();
// Without this every activation would show up as "1.0.0" in the license list.
Deploymentcenter.Client.LicenseClient.DefaultAppVersion = DcConfig.AppVersion;
Log.Warning("══════════════════════════════════════════════════════"); 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(" 📊 First platform report in 5 minutes.");
Log.Warning("══════════════════════════════════════════════════════"); Log.Warning("══════════════════════════════════════════════════════");
mainForm.Initialize(); // ─── License gate: no usable license, no app ───
LicenseSession = LicenseGuard.EnsureLicensed();
if (LicenseSession == null)
{
Log.Information("Keine nutzbare Lizenz — Anwendung wird beendet.");
DcErrorReporter.Shutdown();
Log.CloseAndFlush();
return;
}
mainForm.Initialize(settings);
// While running: re-check the license every 12 h (revocation/expiry/offline grace). // While running: re-check the license every 12 h (revocation/expiry/offline grace).
using var licenseTimer = LicenseGuard.StartPeriodicRevalidation(licenseClient); using var licenseTimer = LicenseGuard.StartPeriodicRevalidation(LicenseSession);
System.Windows.Forms.Application.Run(mainForm); System.Windows.Forms.Application.Run(mainForm);
Log.Information("Application shutting down."); Log.Information("Application shutting down.");
DcErrorReporter.Shutdown();
Log.CloseAndFlush(); Log.CloseAndFlush();
} }
} }
@@ -0,0 +1,96 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// Failed Deployment Center call. <see cref="Code"/> is the stable, machine readable
/// error code from the API envelope ("unauthorized", "rate_limited", ...) — react to it,
/// not to the message text.
/// </summary>
public sealed class DcApiException : Exception
{
public DcApiException(HttpStatusCode statusCode, string? code, string body)
: base($"Deployment Center HTTP {(int)statusCode}{(code is null ? "" : $" ({code})")}: {Shorten(body)}")
{
StatusCode = statusCode;
Code = code;
}
public HttpStatusCode StatusCode { get; }
public string? Code { get; }
/// <summary>True for errors that repeating the same call cannot fix (wrong/missing token).</summary>
public bool IsPermanent =>
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden ||
Code is "unauthorized" or "project_forbidden";
private static string Shorten(string body) =>
body.Length <= 300 ? body : body[..300] + "…";
}
/// <summary>
/// Minimal JSON client for the Deployment Center API. Used by the heartbeat and the error
/// stream; the license and update modules bring their own client (Deploymentcenter.Client).
/// </summary>
public sealed class DcApiClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _http;
private readonly string _token;
public DcApiClient(string token, TimeSpan? timeout = null)
{
_token = token ?? "";
_http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(10) };
}
public async Task<string> PostJsonAsync(string path, object payload, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, DcConfig.BaseUrl + path);
if (!string.IsNullOrWhiteSpace(_token))
{
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_token}");
}
request.Content = new StringContent(
JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new DcApiException(response.StatusCode, ExtractErrorCode(body), body);
}
return body;
}
/// <summary>Reads error.code out of {"status":"error","error":{"code":"…"}}.</summary>
private static string? ExtractErrorCode(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
doc.RootElement.TryGetProperty("error", out var error) &&
error.ValueKind == JsonValueKind.Object &&
error.TryGetProperty("code", out var code))
{
return code.GetString();
}
}
catch (JsonException)
{
// Not every error path answers with the envelope (e.g. a proxy returning HTML).
}
return null;
}
public void Dispose() => _http.Dispose();
}
@@ -0,0 +1,37 @@
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// Compile-time settings for the Deployment Center (https://dc.mhdf.de), which replaced
/// the separate Watchdog and LicenseLabrador servers.
///
/// The base URL is deliberately NOT user configuration: it decides where the license check
/// goes and where update packages are downloaded from. A configurable endpoint would let
/// anyone point the app at a fake license or update server.
/// </summary>
public static class DcConfig
{
public const string BaseUrl = "https://dc.mhdf.de";
/// <summary>Slug in dc_projects — license, UpdateService, error stream and bugtracker share it.</summary>
public const string ProductSlug = "predictalytics";
/// <summary>
/// Reported to the license activation list, the monitor and the error stream.
/// Generated by Deploymentcenter.BuildInfo.targets from &lt;Version&gt; in the csproj —
/// bump it there when releasing, it is what the UpdateService compares against.
/// </summary>
public static string AppVersion => BuildInfo.Version;
/// <summary>Commit this build came from — travels with error reports.</summary>
public static string GitCommitShort => BuildInfo.GitCommitShort;
/// <summary>Dashboard grouping of the Watchdog monitor.</summary>
public const string MonitorGroup = "Applications";
/// <summary>Value for the "environment" field of the error stream.</summary>
#if DEBUG
public const string Environment = "development";
#else
public const string Environment = "production";
#endif
}
@@ -0,0 +1,247 @@
using System.Diagnostics;
using Serilog;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// Forwards runtime errors to the Deployment Center error stream
/// (POST /api/errors/v1/report). The server groups identical errors, counts them up and
/// silences known-harmless ones via ignore rules — so this reports rather than filters.
///
/// Static on purpose: the Serilog sink and the global exception handlers are wired up
/// before the settings are known, and both have to reach the same rate limiter.
/// </summary>
public static class DcErrorReporter
{
/// <summary>Server limit is 60 reports per minute and IP — stay well below it.</summary>
private const int MaxReportsPerMinute = 20;
/// <summary>The same error is only reported again after this interval (the server counts it up anyway).</summary>
private static readonly TimeSpan RepeatSuppression = TimeSpan.FromMinutes(5);
private static readonly object Sync = new();
private static readonly Dictionary<string, DateTime> RecentSignatures = new();
private static DcApiClient? _api;
private static bool _enabled;
private static bool _tokenRejected;
private static DateTime _windowStartUtc = DateTime.UtcNow;
private static int _sentInWindow;
public static bool IsEnabled => _enabled && _api is not null && !_tokenRejected;
/// <summary>(Re-)configures the reporter. An empty token switches it off.</summary>
public static void Configure(string token, bool enabled)
{
lock (Sync)
{
_api?.Dispose();
_api = null;
_tokenRejected = false;
if (!enabled || string.IsNullOrWhiteSpace(token))
{
_enabled = false;
return;
}
_api = new DcApiClient(token);
_enabled = true;
}
}
public static void Shutdown()
{
lock (Sync)
{
_enabled = false;
_api?.Dispose();
_api = null;
}
}
/// <summary>
/// Installs the global handlers. Without them an unhandled exception ends the process
/// without a trace in the error stream — exactly the case the stream exists for.
/// </summary>
public static void InstallGlobalHandlers()
{
// Fully qualified: "Application" alone binds to the Predictalytics.Application namespace.
System.Windows.Forms.Application.ThreadException += (_, e) =>
{
Log.Error(e.Exception, "Unbehandelte Ausnahme im UI-Thread");
Report(e.Exception, "error");
MessageBox.Show(
$"Ein unerwarteter Fehler ist aufgetreten:\n\n{e.Exception.Message}\n\n" +
"Der Fehler wurde an das Deployment Center gemeldet.",
"Predictalytics", MessageBoxButtons.OK, MessageBoxIcon.Error);
};
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();
};
}
public static void Report(Exception exception, string level = "error", bool blocking = false)
{
if (!IsEnabled) return;
var inner = Unwrap(exception);
var (file, line) = ResolveOrigin(inner);
Send(
exceptionType: inner.GetType().FullName ?? inner.GetType().Name,
message: inner.Message,
stackTrace: exception.ToString(),
level: level,
file: file,
line: line,
blocking: blocking);
}
/// <summary>Reports a logged error that carries no exception (Log.Error("..." )).</summary>
public static void ReportMessage(string exceptionType, string message, string? stackTrace, string level)
{
if (!IsEnabled) return;
Send(exceptionType, message, stackTrace, level, null, null, blocking: false);
}
private static void Send(
string exceptionType, string message, string? stackTrace, string level,
string? file, int? line, bool blocking)
{
DcApiClient api;
lock (Sync)
{
if (_api is null || !_enabled || _tokenRejected) return;
if (!PassesRateLimit(exceptionType, message)) return;
api = _api;
}
var payload = new
{
project_slug = DcConfig.ProductSlug,
exception = exceptionType,
message = Truncate(message, 2000),
stack_trace = Truncate(stackTrace, 8000),
level,
build = DcConfig.AppVersion,
environment = DcConfig.Environment,
file,
line,
// Tells apart reports coming from several installations of the same build,
// and pins the report to an exact commit.
context = new { host = System.Environment.MachineName, commit = DcConfig.GitCommitShort }
};
var task = PostAsync(api, payload);
if (blocking)
{
task.Wait(TimeSpan.FromSeconds(5));
}
}
private static async Task PostAsync(DcApiClient api, object payload)
{
try
{
await api.PostJsonAsync("/api/errors/v1/report", payload).ConfigureAwait(false);
}
catch (DcApiException ex) when (ex.IsPermanent)
{
lock (Sync) { _tokenRejected = true; }
// Debug level on purpose: a warning here would be logged, land in the sink and
// come straight back as the next report.
Log.Debug("Deployment Center error stream rejected the token ({Code}); reporting disabled.", ex.Code);
}
catch (Exception ex)
{
Log.Debug(ex, "Deployment Center error report failed");
}
}
/// <summary>Local budget: the server counts duplicates itself, we only avoid burning the rate limit.</summary>
private static bool PassesRateLimit(string exceptionType, string message)
{
var now = DateTime.UtcNow;
if (now - _windowStartUtc > TimeSpan.FromMinutes(1))
{
_windowStartUtc = now;
_sentInWindow = 0;
}
if (_sentInWindow >= MaxReportsPerMinute) return false;
var signature = exceptionType + "|" + Truncate(message, 200);
if (RecentSignatures.TryGetValue(signature, out var last) && now - last < RepeatSuppression)
{
return false;
}
if (RecentSignatures.Count > 500) RecentSignatures.Clear();
RecentSignatures[signature] = now;
_sentInWindow++;
return true;
}
/// <summary>
/// AggregateException and TargetInvocationException say nothing about the actual defect;
/// grouping on them would throw unrelated errors into one bucket.
/// </summary>
private static Exception Unwrap(Exception exception)
{
while (exception is AggregateException { InnerExceptions.Count: 1 } aggregate)
{
exception = aggregate.InnerExceptions[0];
}
return exception;
}
/// <summary>Reads file and line from the first stack frame that has debug info (PDB present).</summary>
private static (string? File, int? Line) ResolveOrigin(Exception exception)
{
try
{
var trace = new StackTrace(exception, fNeedFileInfo: true);
foreach (var frame in trace.GetFrames())
{
var file = frame.GetFileName();
if (string.IsNullOrEmpty(file)) continue;
var lineNo = frame.GetFileLineNumber();
return (ToRepoRelative(file!), lineNo > 0 ? lineNo : (int?)null);
}
}
catch
{
// Origin is a nicety, never a reason to drop the report.
}
return (null, null);
}
/// <summary>Turns C:\build\...\src\Foo\Bar.cs into src/Foo/Bar.cs so the path matches the repo.</summary>
private static string ToRepoRelative(string path)
{
var normalized = path.Replace('\\', '/');
var marker = normalized.LastIndexOf("/src/", StringComparison.OrdinalIgnoreCase);
return marker >= 0 ? normalized[(marker + 1)..] : normalized;
}
private static string? Truncate(string? value, int max)
{
if (string.IsNullOrEmpty(value)) return value;
return value!.Length <= max ? value : value[..max] + "…";
}
}
@@ -0,0 +1,39 @@
using Serilog.Core;
using Serilog.Events;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// Serilog sink that forwards Error and Fatal events to the Deployment Center error stream.
///
/// It is registered while the logger is being built, long before the settings are known —
/// <see cref="DcErrorReporter"/> is asked at emit time whether reporting is switched on, so
/// toggling the setting takes effect without rebuilding the logger.
/// </summary>
public sealed class DcErrorSink : ILogEventSink
{
public void Emit(LogEvent logEvent)
{
if (logEvent.Level < LogEventLevel.Error) return;
if (!DcErrorReporter.IsEnabled) return;
var level = logEvent.Level == LogEventLevel.Fatal ? "fatal" : "error";
if (logEvent.Exception is not null)
{
DcErrorReporter.Report(logEvent.Exception, level);
return;
}
// No exception attached: the rendered message is all the identity this error has.
var source = logEvent.Properties.TryGetValue("SourceContext", out var ctx)
? ctx.ToString().Trim('"')
: "Predictalytics";
DcErrorReporter.ReportMessage(
exceptionType: source,
message: logEvent.RenderMessage(),
stackTrace: null,
level: level);
}
}
@@ -0,0 +1,217 @@
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using Serilog;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>Single self-assessed health check sent along with a heartbeat.</summary>
public sealed class DcCheck
{
public DcCheck(bool ok, string? message = null, double? value = null)
{
Ok = ok;
Message = message;
Value = value;
}
[JsonPropertyName("ok")] public bool Ok { get; }
[JsonPropertyName("message")] public string? Message { get; }
[JsonPropertyName("value")] public double? Value { get; }
}
/// <summary>What the application reports about itself in one heartbeat.</summary>
public sealed class DcHeartbeatSnapshot
{
/// <summary>ok | warning | error — "stopped"/"maintenance" are sent by the service itself.</summary>
public string Status { get; set; } = "ok";
public string? Message { get; set; }
/// <summary>A failing check downgrades an "ok" heartbeat to "warning" on the server.</summary>
public Dictionary<string, DcCheck> Checks { get; } = new();
/// <summary>Numeric values; the server keeps 14 days of history per metric.</summary>
public Dictionary<string, double> Metrics { get; } = new();
}
/// <summary>
/// Sends periodic heartbeats to the Deployment Center Watchdog
/// (POST /api/watchdog/v1/ping) so an outage of this app — or of the whole machine —
/// raises an alarm. A Deployment Center outage must never impact the app: every call
/// is best effort.
///
/// The server evaluates by interval: no heartbeat for more than 2× the interval means
/// "warning", more than 4× means "down".
/// </summary>
public sealed class DcHeartbeatService : IDisposable
{
private readonly DcApiClient _api;
private readonly string _source;
private readonly string _instance;
private readonly int _intervalSeconds;
private readonly Func<CancellationToken, Task<DcHeartbeatSnapshot>>? _snapshotProvider;
private readonly DateTime _startedUtc = DateTime.UtcNow;
// Guards against overlapping sends when a call takes longer than the interval.
private readonly SemaphoreSlim _sendGate = new(1, 1);
private System.Threading.Timer? _timer;
private bool _lastSendFailed;
private bool _tokenRejected;
private bool _stoppingNotified;
private volatile bool _disposed;
public DcHeartbeatService(
string token,
string source,
string instance,
int intervalSeconds,
Func<CancellationToken, Task<DcHeartbeatSnapshot>>? snapshotProvider = null)
{
_api = new DcApiClient(token);
_source = string.IsNullOrWhiteSpace(source) ? "Predictalytics" : source;
_instance = string.IsNullOrWhiteSpace(instance) ? "default" : instance;
_intervalSeconds = Math.Max(15, intervalSeconds);
_snapshotProvider = snapshotProvider;
}
public void Start()
{
_timer?.Dispose();
_timer = new System.Threading.Timer(
async _ => await SendHeartbeatAsync().ConfigureAwait(false),
null, TimeSpan.Zero, TimeSpan.FromSeconds(_intervalSeconds));
Log.Information("🐕 Deployment Center Heartbeat gestartet → {Url} (source={Source}, alle {Interval}s)",
DcConfig.BaseUrl, _source, _intervalSeconds);
}
/// <summary>
/// Announces a planned shutdown as status "stopped". The evaluator leaves such a monitor
/// alone until a normal heartbeat arrives again — without it, every orderly shutdown
/// produces a false alarm a few minutes later.
/// </summary>
public void NotifyStopping()
{
if (_stoppingNotified) return;
_stoppingNotified = true;
try
{
_timer?.Dispose();
_timer = null;
var payload = BuildPayload("stopped", "Predictalytics wird planmäßig beendet.", null, null);
// Synchronous with a short cap: the form is closing and must not hang.
_api.PostJsonAsync("/api/watchdog/v1/ping", payload).Wait(TimeSpan.FromSeconds(4));
}
catch
{
// Best effort only.
}
}
/// <remarks>
/// Invoked from a timer callback, i.e. as async void — an exception escaping here would
/// take the whole process down. Nothing in this method may throw.
/// </remarks>
private async Task SendHeartbeatAsync()
{
if (_disposed || _tokenRejected) return;
if (!await _sendGate.WaitAsync(0).ConfigureAwait(false)) return;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_intervalSeconds));
DcHeartbeatSnapshot snapshot;
try
{
snapshot = _snapshotProvider is null
? new DcHeartbeatSnapshot()
: await _snapshotProvider(cts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
// Collecting the state must never keep the heartbeat from going out —
// that would turn a diagnostic hiccup into a false "down".
snapshot = new DcHeartbeatSnapshot { Status = "warning", Message = $"Statusermittlung fehlgeschlagen: {ex.Message}" };
}
snapshot.Metrics["uptime_sec"] = Math.Round((DateTime.UtcNow - _startedUtc).TotalSeconds);
var payload = BuildPayload(snapshot.Status, snapshot.Message, snapshot.Checks, snapshot.Metrics);
await _api.PostJsonAsync("/api/watchdog/v1/ping", payload, cts.Token).ConfigureAwait(false);
if (_lastSendFailed)
{
_lastSendFailed = false;
Log.Information("🐕 Deployment Center Heartbeat wieder erfolgreich zugestellt.");
}
}
catch (DcApiException ex) when (ex.IsPermanent)
{
// Retrying cannot help — a rejected token would otherwise log forever.
_tokenRejected = true;
Log.Warning("🐕 Deployment Center weist das Token zurück ({Code}) — Heartbeats werden eingestellt. " +
"Bitte in den Settings ein Token mit dem Recht 'watchdog:ping' eintragen.", ex.Code ?? "unauthorized");
}
catch (Exception ex)
{
// Log the first failure as warning, subsequent ones quietly (no log flood).
if (!_lastSendFailed)
{
_lastSendFailed = true;
Log.Warning("🐕 Deployment Center Heartbeat fehlgeschlagen (weitere Fehler werden unterdrückt): {Error}", ex.Message);
}
else
{
Log.Debug(ex, "Deployment Center heartbeat failed");
}
}
finally
{
_sendGate.Release();
}
}
public void Dispose()
{
_disposed = true;
_timer?.Dispose();
_timer = null;
// Give a send in flight a moment to finish before the HttpClient goes away. Called
// from the UI thread (settings change, form closing), so the wait stays short — if it
// expires, the pending call just fails and gets logged like any other network error.
if (_sendGate.Wait(TimeSpan.FromMilliseconds(250)))
{
_sendGate.Release();
}
_api.Dispose();
// _sendGate is deliberately not disposed: a late Release() on a disposed semaphore
// would throw on the timer thread for no gain — SemaphoreSlim without a wait handle
// holds no unmanaged resources.
}
private object BuildPayload(
string status,
string? message,
Dictionary<string, DcCheck>? checks,
Dictionary<string, double>? metrics) => new
{
source = _source,
instance = _instance,
type = "heartbeat",
status,
interval = _intervalSeconds,
message,
group = DcConfig.MonitorGroup,
os = $"{RuntimeInformation.OSDescription} / .NET {System.Environment.Version}",
// Since 2.1 the monitor can show which build is running — that is what ties
// "this monitor went down" to "we rolled out 1.4.3 an hour ago".
version = DcConfig.AppVersion,
checks = checks is { Count: > 0 } ? checks : null,
metrics = metrics is { Count: > 0 } ? metrics : null
};
}
@@ -0,0 +1,58 @@
using Deploymentcenter.Client;
using Serilog;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// Checks the Deployment Center UpdateService for a newer release
/// (GET /api/updateservice/v1/check, no token needed).
///
/// The check itself only reads metadata. Installing is done by the standalone
/// update-agent, which replaces the running installation and therefore has to be
/// started explicitly by the user.
/// </summary>
public static class DcUpdateService
{
private const string AgentFileName = "update-agent.exe";
public static async Task<UpdateCheckResult> CheckAsync(string channel, CancellationToken ct = default)
{
var client = new UpdateClient();
return await client.CheckForUpdateAsync(
baseUrl: DcConfig.BaseUrl,
projectId: DcConfig.ProductSlug,
currentVersion: DcConfig.AppVersion,
channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel,
cancellationToken: ct).ConfigureAwait(false);
}
/// <summary>Path of the update agent next to the exe, or null if it was not deployed.</summary>
public static string? FindUpdateAgent()
{
var path = Path.Combine(AppContext.BaseDirectory, AgentFileName);
return File.Exists(path) ? path : null;
}
/// <summary>
/// Hands control to the update agent and ends this process. Returns false if the agent
/// is not present — then the update has to be installed by hand.
/// </summary>
public static bool LaunchAgent(string channel)
{
var agent = FindUpdateAgent();
if (agent is null)
{
Log.Warning("Update-Agent ({Agent}) liegt nicht neben der Anwendung — Update bitte manuell einspielen.", AgentFileName);
return false;
}
Log.Information("Starte Update-Agent {Agent} (Kanal {Channel}) und beende die Anwendung...", agent, channel);
return UpdateClient.LaunchUpdateAgent(
agentPath: agent,
projectId: DcConfig.ProductSlug,
channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel,
action: "update",
version: "latest",
exitCurrentApp: true);
}
}
@@ -1,11 +1,11 @@
using LicenseLabrador.Client; using Deploymentcenter.Client;
namespace Predictalytics.WinFormsHost.Services; namespace Predictalytics.WinFormsHost.Services;
/// <summary> /// <summary>
/// Modal dialog shown at startup when no usable license is present. /// 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 /// Lets the user enter/activate a license key; closes with OK only after the Deployment
/// successful, checksum-verified validation. /// Center confirmed the activation for this machine.
/// </summary> /// </summary>
public sealed class LicenseDialog : Form public sealed class LicenseDialog : Form
{ {
@@ -15,9 +15,10 @@ public sealed class LicenseDialog : Form
private readonly Button _btnActivate; private readonly Button _btnActivate;
private readonly Button _btnExit; private readonly Button _btnExit;
public LicenseResult? Result { get; private set; } /// <summary>Set once the activation succeeded.</summary>
public LicenseSession? Session { get; private set; }
public LicenseDialog(LicenseClient client, LicenseResult? lastResult) public LicenseDialog(LicenseClient client, HardwareIdResult hardware, LicenseValidationResult? lastResult)
{ {
_client = client; _client = client;
@@ -26,27 +27,38 @@ public sealed class LicenseDialog : Form
MaximizeBox = false; MaximizeBox = false;
MinimizeBox = false; MinimizeBox = false;
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
ClientSize = new Size(460, 190); ClientSize = new Size(560, 230);
var lblInfo = new Label var lblInfo = new Label
{ {
Text = "Diese Installation benötigt eine gültige Lizenz.\nBitte Lizenzschlüssel eingeben (Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX):", Text = "Diese Installation benötigt eine gültige Lizenz.\nBitte Lizenzschlüssel eingeben (Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX):",
Location = new Point(12, 12), Location = new Point(12, 12),
Size = new Size(436, 34) Size = new Size(536, 34)
}; };
_txtKey = new TextBox _txtKey = new TextBox
{ {
Location = new Point(12, 52), Location = new Point(12, 52),
Size = new Size(436, 26), Size = new Size(536, 26),
Font = new Font("Consolas", 11f), Font = new Font("Consolas", 11f),
CharacterCasing = CharacterCasing.Upper CharacterCasing = CharacterCasing.Upper
}; };
// The hardware ID is what the activation is bound to — without it, support cannot
// tell which slot to release when a machine is replaced.
var lblHardware = new Label
{
Text = $"Hardware-ID: {hardware.HardwareId} (Quelle: {hardware.HwidSource})",
Location = new Point(12, 84),
Size = new Size(536, 20),
ForeColor = Color.DimGray,
AutoEllipsis = true
};
_lblStatus = new Label _lblStatus = new Label
{ {
Location = new Point(12, 84), Location = new Point(12, 110),
Size = new Size(436, 50), Size = new Size(536, 62),
ForeColor = Color.Firebrick, ForeColor = Color.Firebrick,
Text = FormatInitialStatus(lastResult) Text = FormatInitialStatus(lastResult)
}; };
@@ -54,7 +66,7 @@ public sealed class LicenseDialog : Form
_btnActivate = new Button _btnActivate = new Button
{ {
Text = "Aktivieren", Text = "Aktivieren",
Location = new Point(252, 146), Location = new Point(352, 186),
Size = new Size(96, 30) Size = new Size(96, 30)
}; };
_btnActivate.Click += async (_, _) => await ActivateAsync(); _btnActivate.Click += async (_, _) => await ActivateAsync();
@@ -62,20 +74,29 @@ public sealed class LicenseDialog : Form
_btnExit = new Button _btnExit = new Button
{ {
Text = "Beenden", Text = "Beenden",
Location = new Point(354, 146), Location = new Point(454, 186),
Size = new Size(94, 30), Size = new Size(94, 30),
DialogResult = DialogResult.Cancel DialogResult = DialogResult.Cancel
}; };
AcceptButton = _btnActivate; AcceptButton = _btnActivate;
CancelButton = _btnExit; CancelButton = _btnExit;
Controls.AddRange(new Control[] { lblInfo, _txtKey, _lblStatus, _btnActivate, _btnExit }); Controls.AddRange(new Control[] { lblInfo, _txtKey, lblHardware, _lblStatus, _btnActivate, _btnExit });
} }
private static string FormatInitialStatus(LicenseResult? lastResult) private static string FormatInitialStatus(LicenseValidationResult? lastResult)
{ {
if (lastResult == null || lastResult.State == LicenseState.NoLicense) return ""; if (lastResult is null) return "";
return $"Letzte Prüfung: {lastResult.State} — {lastResult.Message}";
// IsTransient means the server gave no verdict at all — telling the user their
// license is bad would be wrong, the connection is.
if (lastResult.IsTransient)
{
return "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;
}
return $"Letzte Prüfung: {lastResult.Status} — {lastResult.Message}";
} }
private async Task ActivateAsync() private async Task ActivateAsync()
@@ -83,27 +104,28 @@ public sealed class LicenseDialog : Form
var key = _txtKey.Text.Trim(); var key = _txtKey.Text.Trim();
if (string.IsNullOrWhiteSpace(key)) if (string.IsNullOrWhiteSpace(key))
{ {
_lblStatus.ForeColor = Color.Firebrick;
_lblStatus.Text = "Bitte einen Lizenzschlüssel eingeben."; _lblStatus.Text = "Bitte einen Lizenzschlüssel eingeben.";
return; return;
} }
_btnActivate.Enabled = false; _btnActivate.Enabled = false;
_lblStatus.ForeColor = Color.DimGray; _lblStatus.ForeColor = Color.DimGray;
_lblStatus.Text = "Prüfe Lizenz am Server..."; _lblStatus.Text = "Prüfe Lizenz am Deployment Center...";
try try
{ {
var result = await _client.ValidateAsync(key); var result = await _client.ValidateAsync(DcConfig.ProductSlug, key, DcConfig.BaseUrl);
if (result.IsUsable && _client.VerifyChecksum(result)) if (result.IsValid)
{ {
Result = result; Session = new LicenseSession(_client, key, result);
DialogResult = DialogResult.OK; DialogResult = DialogResult.OK;
Close(); Close();
return; return;
} }
_lblStatus.ForeColor = Color.Firebrick; _lblStatus.ForeColor = Color.Firebrick;
_lblStatus.Text = $"Lizenz nicht nutzbar ({result.State}):\n{result.Message}"; _lblStatus.Text = $"Lizenz nicht nutzbar ({result.Status}):\n{result.Message}";
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1,102 +1,155 @@
using LicenseLabrador.Client; using Deploymentcenter.Client;
using Serilog; using Serilog;
namespace Predictalytics.WinFormsHost.Services; namespace Predictalytics.WinFormsHost.Services;
/// <summary>An activated license: the key belonging to this machine plus the last result.</summary>
public sealed class LicenseSession
{
public LicenseSession(LicenseClient client, string licenseKey, LicenseValidationResult result)
{
Client = client;
LicenseKey = licenseKey;
LastResult = result;
}
public LicenseClient Client { get; }
public string LicenseKey { get; }
public LicenseValidationResult LastResult { get; internal set; }
}
/// <summary> /// <summary>
/// Startup license gate backed by the LicenseLabrador server. /// Startup license gate backed by the Deployment Center (POST /api/license/v1/validate).
/// Endpoint, product slug and the Ed25519 public key are deliberately compiled in ///
/// (not user configuration): a configurable endpoint/key would let anyone point the /// Endpoint and product slug are deliberately compiled in (see <see cref="DcConfig"/>):
/// app at a fake license server. /// a configurable endpoint would let anyone point the app at a fake license server.
/// </summary> /// </summary>
public static class LicenseGuard public static class LicenseGuard
{ {
private const string ProductSlug = "predictalytics";
private const string PublicKeyBase64 = "L7YR1wMKk8+lNefatzL+DMvAtHFVkZWYXAxXGrro+/U=";
private const string BasicAuthUser = "Labrador";
private const string BasicAuthPassword = "Labrador02763!";
// HTTPS ist Pflicht, nicht Kosmetik: license.mhdf.de leitet http→https um, und .NET
// macht bei einem Redirect aus dem POST ein GET. Der Server antwortet darauf mit 405,
// das SDK wertet das als "unerreichbar" und meldet irrefuehrend NoLicense.
// Ausserdem gingen die BasicAuth-Credentials sonst im Klartext ueber die Leitung.
private static readonly string[] Endpoints = { "https://license.mhdf.de/public/api/v1" };
/// <summary>Re-check interval while the app is running (12 h).</summary> /// <summary>Re-check interval while the app is running (12 h).</summary>
public const int RevalidationIntervalMs = 12 * 60 * 60 * 1000; public const int RevalidationIntervalMs = 12 * 60 * 60 * 1000;
public static LicenseClient CreateClient() public static LicenseClient CreateClient() => new();
{
var config = new LicenseConfig /// <summary>Hardware ID v2 of this machine — shown in the dialog and needed for support.</summary>
{ public static HardwareIdResult GetHardwareInfo() => HardwareId.GetHardwareId(DcConfig.ProductSlug);
ProductSlug = ProductSlug,
PublicKeyBase64 = PublicKeyBase64,
Endpoints = Endpoints,
HttpBasicAuthUser = BasicAuthUser,
HttpBasicAuthPassword = BasicAuthPassword,
OfflineGraceHoursFallback = 168 // 7 Tage offline nutzbar, danach Serverkontakt nötig
};
return new LicenseClient(config);
}
/// <summary> /// <summary>
/// Blocks until a usable license is present. Tries the cached key first; otherwise /// Blocks until a usable license is present. Tries the key from the encrypted local cache
/// (or when the cached key is no longer usable) shows the license dialog. /// first; otherwise (or when that key is no longer usable) shows the license dialog.
/// Returns null if the user gave up — the app must exit then. /// Returns null if the user gave up — the app must exit then.
/// </summary> /// </summary>
public static LicenseClient? EnsureLicensed() public static LicenseSession? EnsureLicensed()
{ {
var client = CreateClient(); var client = CreateClient();
var hardware = GetHardwareInfo();
var result = client.RevalidateAsync().GetAwaiter().GetResult(); // The client takes the key as a parameter on every call; the key of the last
if (result.IsUsable && client.VerifyChecksum(result)) // successful activation lives in the encrypted local cache.
var cachedKey = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
LicenseValidationResult? lastResult = null;
if (!string.IsNullOrWhiteSpace(cachedKey))
{ {
return client; lastResult = client.ValidateAsync(DcConfig.ProductSlug, cachedKey!, DcConfig.BaseUrl)
.GetAwaiter().GetResult();
if (lastResult.IsValid)
{
Log.Information("🔑 Lizenz geprüft: {Status}{Cached} (HWID {Hwid}, Quelle {Source})",
lastResult.Status, lastResult.IsCached ? " — aus Offline-Cache" : "",
hardware.HardwareId, hardware.HwidSource);
WarnIfGraceRunningOut(lastResult);
return new LicenseSession(client, cachedKey!, lastResult);
} }
using var dialog = new LicenseDialog(client, result); Log.Warning("🔑 Gespeicherter Lizenzschlüssel nicht nutzbar ({Status}): {Message}",
if (dialog.ShowDialog() != DialogResult.OK) lastResult.Status, lastResult.Message);
}
using var dialog = new LicenseDialog(client, hardware, lastResult);
if (dialog.ShowDialog() != DialogResult.OK || dialog.Session is null)
{ {
return null; return null;
} }
return client; return dialog.Session;
} }
/// <summary> /// <summary>
/// Starts the periodic in-app revalidation. Detects revocation/expiry while the app /// Starts the periodic in-app revalidation. Detects revocation/expiry while the app keeps
/// keeps running; on a definitively unusable license the app is shut down. /// running; only a definitive negative shuts the app down — a server outage must not.
/// </summary> /// </summary>
public static System.Windows.Forms.Timer StartPeriodicRevalidation(LicenseClient client) public static System.Windows.Forms.Timer StartPeriodicRevalidation(LicenseSession session)
{ {
var timer = new System.Windows.Forms.Timer { Interval = RevalidationIntervalMs }; var timer = new System.Windows.Forms.Timer { Interval = RevalidationIntervalMs };
timer.Tick += async (_, _) => timer.Tick += async (_, _) =>
{ {
try try
{ {
var result = await client.RevalidateAsync(); var result = await session.Client.ValidateAsync(
if (result.IsUsable && client.VerifyChecksum(result)) DcConfig.ProductSlug, session.LicenseKey, DcConfig.BaseUrl);
session.LastResult = result;
if (result.IsValid)
{ {
if (result.State == LicenseState.ValidOffline) if (result.IsCached)
{ {
Log.Warning("Lizenzserver nicht erreichbar — Offline-Gnadenfrist läuft bis {GraceUntil}.", result.GraceUntil); Log.Warning("Lizenzserver nicht erreichbar — Prüfung erfolgte aus dem Offline-Cache.");
}
WarnIfGraceRunningOut(result);
return;
}
if (result.IsTransient)
{
// server_unavailable / cache_expired: no verdict, only a failed connection.
// A running installation must not be shut down for that — but an exhausted
// grace period is worth more than a warning: the next start will stop at
// the license dialog.
if (result.Status.Equals("cache_expired", StringComparison.OrdinalIgnoreCase))
{
Log.Error("Offline-Gnadenfrist abgelaufen und das Deployment Center ist nicht erreichbar. " +
"Diese Sitzung läuft weiter, der nächste Start verlangt aber eine Online-Prüfung: {Message}",
result.Message);
}
else
{
Log.Warning("Lizenz-Revalidierung vorläufig fehlgeschlagen ({Status}): {Message} — wird erneut versucht.",
result.Status, result.Message);
} }
return; return;
} }
timer.Stop(); timer.Stop();
Log.Fatal("Lizenzprüfung fehlgeschlagen ({State}): {Message} — Anwendung wird beendet.", result.State, result.Message); Log.Fatal("Lizenzprüfung fehlgeschlagen ({Status}): {Message} — Anwendung wird beendet.",
result.Status, result.Message);
MessageBox.Show( MessageBox.Show(
$"Die Lizenz ist nicht mehr gültig ({result.State}):\n{result.Message}\n\nPredictalytics wird beendet.", $"Die Lizenz ist nicht mehr gültig ({result.Status}):\n{result.Message}\n\nPredictalytics wird beendet.",
"Lizenzfehler", MessageBoxButtons.OK, MessageBoxIcon.Stop); "Lizenzfehler", MessageBoxButtons.OK, MessageBoxIcon.Stop);
System.Windows.Forms.Application.Exit(); System.Windows.Forms.Application.Exit();
} }
catch (Exception ex) catch (Exception ex)
{ {
// Transient errors (network etc.) are handled by the SDK's offline grace — // Never kill the app from an unexpected exception here.
// never kill the app from an unexpected exception here.
Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht)."); Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht).");
} }
}; };
timer.Start(); timer.Start();
return timer; return timer;
} }
/// <summary>
/// Since 2.1 the offline grace period is really bounded (cache_ttl_hours, default 168 h).
/// A machine that is offline on purpose should learn that before it runs out, not after.
/// </summary>
private static void WarnIfGraceRunningOut(LicenseValidationResult result)
{
if (result.CacheExpiresAt is not { } expiresAt || expiresAt <= 0) return;
var remaining = DateTimeOffset.FromUnixTimeSeconds(expiresAt) - DateTimeOffset.UtcNow;
if (remaining > TimeSpan.FromHours(48)) return;
Log.Warning("🔑 Offline-Gnadenfrist endet in {Hours:F0} h ({Until:yyyy-MM-dd HH:mm} UTC) — " +
"bis dahin muss das Deployment Center einmal erreichbar sein.",
Math.Max(0, remaining.TotalHours), DateTimeOffset.FromUnixTimeSeconds(expiresAt).UtcDateTime);
}
} }
@@ -1,141 +0,0 @@
using System.Text;
using System.Text.Json;
using Serilog;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// Sends periodic dead-man's-switch heartbeats to the external Watchdog server
/// (POST /api/heartbeat) so an outage of this app — or the whole machine — raises
/// an alarm. A Watchdog outage must never impact the app: every call is best effort.
/// </summary>
public sealed class WatchdogHeartbeatService : IDisposable
{
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
private readonly string _baseUrl;
private readonly string _apiKey;
private readonly string _source;
private readonly string _instance;
private readonly int _intervalSeconds;
private readonly Func<object?>? _metadataProvider;
private readonly DateTime _startedUtc = DateTime.UtcNow;
private System.Threading.Timer? _timer;
private bool _lastSendFailed;
public WatchdogHeartbeatService(
string baseUrl,
string apiKey,
string source,
string instance,
int intervalSeconds,
Func<object?>? metadataProvider = null)
{
_baseUrl = baseUrl.TrimEnd('/');
_apiKey = apiKey;
_source = source;
_instance = string.IsNullOrWhiteSpace(instance) ? "default" : instance;
_intervalSeconds = Math.Max(15, intervalSeconds);
_metadataProvider = metadataProvider;
}
public void Start()
{
_timer?.Dispose();
_timer = new System.Threading.Timer(
async _ => await SendHeartbeatAsync("ok"),
null, TimeSpan.Zero, TimeSpan.FromSeconds(_intervalSeconds));
Log.Information("🐕 Watchdog heartbeat started → {Url} (source={Source}, every {Interval}s)",
_baseUrl, _source, _intervalSeconds);
}
/// <summary>
/// Reports a planned shutdown so it is not alarmed as a crash.
/// Must be "stopped_graceful": the server's event_log.kind ENUM has no "stopping"
/// value, and that variant fails the insert with HTTP 500 after the state update.
/// </summary>
public void NotifyStopping()
{
try
{
var payload = new
{
source = _source,
instance = _instance,
kind = "stopped_graceful",
severity = "info",
message = "Predictalytics wird planmäßig beendet."
};
// Synchronous with a short cap: the form is closing and must not hang.
PostAsync("/api/event", payload).Wait(TimeSpan.FromSeconds(4));
}
catch
{
// Best effort only.
}
}
private async Task SendHeartbeatAsync(string status)
{
try
{
var payload = new
{
source = _source,
instance = _instance,
type = "heartbeat",
status,
message = (string?)null,
metrics = new
{
uptimeSec = (long)(DateTime.UtcNow - _startedUtc).TotalSeconds,
app = _metadataProvider?.Invoke()
},
group = "C# Applications",
interval = _intervalSeconds
};
await PostAsync("/api/heartbeat", payload);
if (_lastSendFailed)
{
_lastSendFailed = false;
Log.Information("🐕 Watchdog heartbeat wieder erfolgreich zugestellt.");
}
}
catch (Exception ex)
{
// Log the first failure as warning, subsequent ones quietly (no log flood).
if (!_lastSendFailed)
{
_lastSendFailed = true;
Log.Warning("🐕 Watchdog heartbeat fehlgeschlagen (weitere Fehler werden unterdrückt): {Error}", ex.Message);
}
else
{
Log.Debug(ex, "Watchdog heartbeat failed");
}
}
}
private async Task PostAsync(string path, object payload)
{
using var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path);
request.Headers.Add("X-Watchdog-Key", _apiKey);
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var response = await _http.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Watchdog API HTTP {(int)response.StatusCode}: {body}");
}
}
public void Dispose()
{
_timer?.Dispose();
_timer = null;
_http.Dispose();
}
}