WinForms-Host durch die Avalonia-Shell abgeloest
Merge von main: die dort entstandene Deployment-Center-Integration (Lizenz, Heartbeat mit DB-Health-Check, Fehler-Stream, UpdateService) ist jetzt Teil des plattformneutralen Kerns. Predictalytics.WinFormsHost ist entfernt. Nach Predictalytics.Hosting gezogen: - DcConfig, DcApiClient, DcErrorSink, DcHeartbeatService, DcUpdateService unveraendert - sie waren bereits plattformneutral - DcErrorReporter ohne Application.ThreadException und MessageBox; der UI-Thread-Handler liegt jetzt beim Host und ruft ReportUiThreadException - LicenseGuard/LicenseSession ohne Dialog und ohne WinForms-Timer. Neu: TryUseCachedAsync, ActivateAsync, StartPeriodicRevalidation ueber PeriodicTimer. Die Unterscheidung transienter Fehler und die Warnung vor ablaufender Gnadenfrist sind unveraendert uebernommen. - Dc-Einstellungen von AppSettings nach PredictalyticsOptions; die Watchdog-Einstellungen entfallen - DcErrorSink im LoggingSetup, Startbanner nutzt DcConfig.AppVersion BuildInfo.targets wird jetzt von Predictalytics.Hosting importiert. In der Avalonia-Shell nachgezogen: - Menue Deployment Center mit Update-Suche und Lizenzstatus - Einstellungsgruppe Deployment Center statt Watchdog, Update-Kanal als ComboBox, Server-URL nur zur Anzeige - Heartbeat-Snapshot mit SELECT-1-Probe wie in der WinForms-Fassung - Update-Pruefung still beim Start und interaktiv ueber das Menue, mit NotifyStopping vor dem Start des Update-Agenten - Lizenzfenster wertet IsTransient aus: bei fehlender Serververbindung wird nicht behauptet, die Lizenz sei ungueltig - TextBox.Watermark auf PlaceholderText (in Avalonia 12 veraltet) Build: 0 Fehler, 8 Warnungen (alle vorbestehend). Tests: 100 bestanden, 0 Fehler, 1 uebersprungen. Verifiziert: --license-status meldet gueltig samt Gnadenfrist; die GUI startet durch, prueft die Lizenz und laeuft gegen den UpdateService. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
<Project Path="src/Predictalytics.Hosting/Predictalytics.Hosting.csproj" />
|
<Project Path="src/Predictalytics.Hosting/Predictalytics.Hosting.csproj" />
|
||||||
<Project Path="src/Predictalytics.Infrastructure/Predictalytics.Infrastructure.csproj" />
|
<Project Path="src/Predictalytics.Infrastructure/Predictalytics.Infrastructure.csproj" />
|
||||||
<Project Path="src/Predictalytics.Shell/Predictalytics.Shell.csproj" />
|
<Project Path="src/Predictalytics.Shell/Predictalytics.Shell.csproj" />
|
||||||
<Project Path="src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj" />
|
|
||||||
<Project Path="src/Predictalytics.Worker/Predictalytics.Worker.csproj" />
|
<Project Path="src/Predictalytics.Worker/Predictalytics.Worker.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
|
||||||
@@ -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)
|
## 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
|
**Nicht ursprünglich geplant.** Beim Aktivieren der neuen Lizenz stellte sich heraus, dass
|
||||||
Predictalytics noch am alten Lizenzsystem hing (LicenseLabrador, `license.mhdf.de`),
|
Predictalytics noch am alten Lizenzsystem hing (LicenseLabrador, `license.mhdf.de`),
|
||||||
während die Lizenzen inzwischen im Deploymentcenter (`dc.mhdf.de`) gepflegt werden.
|
während die Lizenzen inzwischen im Deploymentcenter (`dc.mhdf.de`) gepflegt werden.
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Failed Deployment Center call. <see cref="Code"/> is the stable, machine readable
|
||||||
|
/// error code from the API envelope ("unauthorized", "rate_limited", ...) — react to it,
|
||||||
|
/// not to the message text.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DcApiException : Exception
|
||||||
|
{
|
||||||
|
public DcApiException(HttpStatusCode statusCode, string? code, string body)
|
||||||
|
: base($"Deployment Center HTTP {(int)statusCode}{(code is null ? "" : $" ({code})")}: {Shorten(body)}")
|
||||||
|
{
|
||||||
|
StatusCode = statusCode;
|
||||||
|
Code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpStatusCode StatusCode { get; }
|
||||||
|
public string? Code { get; }
|
||||||
|
|
||||||
|
/// <summary>True for errors that repeating the same call cannot fix (wrong/missing token).</summary>
|
||||||
|
public bool IsPermanent =>
|
||||||
|
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden ||
|
||||||
|
Code is "unauthorized" or "project_forbidden";
|
||||||
|
|
||||||
|
private static string Shorten(string body) =>
|
||||||
|
body.Length <= 300 ? body : body[..300] + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal JSON client for the Deployment Center API. Used by the heartbeat and the error
|
||||||
|
/// stream; the license and update modules bring their own client (Deploymentcenter.Client).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DcApiClient : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly string _token;
|
||||||
|
|
||||||
|
public DcApiClient(string token, TimeSpan? timeout = null)
|
||||||
|
{
|
||||||
|
_token = token ?? "";
|
||||||
|
_http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(10) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> PostJsonAsync(string path, object payload, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, DcConfig.BaseUrl + path);
|
||||||
|
if (!string.IsNullOrWhiteSpace(_token))
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_token}");
|
||||||
|
}
|
||||||
|
request.Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new DcApiException(response.StatusCode, ExtractErrorCode(body), body);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads error.code out of {"status":"error","error":{"code":"…"}}.</summary>
|
||||||
|
private static string? ExtractErrorCode(string body)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
|
||||||
|
doc.RootElement.TryGetProperty("error", out var error) &&
|
||||||
|
error.ValueKind == JsonValueKind.Object &&
|
||||||
|
error.TryGetProperty("code", out var code))
|
||||||
|
{
|
||||||
|
return code.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Not every error path answers with the envelope (e.g. a proxy returning HTML).
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _http.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compile-time settings for the Deployment Center (https://dc.mhdf.de), which replaced
|
||||||
|
/// the separate Watchdog and LicenseLabrador servers.
|
||||||
|
///
|
||||||
|
/// The base URL is deliberately NOT user configuration: it decides where the license check
|
||||||
|
/// goes and where update packages are downloaded from. A configurable endpoint would let
|
||||||
|
/// anyone point the app at a fake license or update server.
|
||||||
|
/// </summary>
|
||||||
|
public static class DcConfig
|
||||||
|
{
|
||||||
|
public const string BaseUrl = "https://dc.mhdf.de";
|
||||||
|
|
||||||
|
/// <summary>Slug in dc_projects — license, UpdateService, error stream and bugtracker share it.</summary>
|
||||||
|
public const string ProductSlug = "predictalytics";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reported to the license activation list, the monitor and the error stream.
|
||||||
|
/// Generated by Deploymentcenter.BuildInfo.targets from <Version> in the csproj —
|
||||||
|
/// bump it there when releasing, it is what the UpdateService compares against.
|
||||||
|
/// </summary>
|
||||||
|
public static string AppVersion => BuildInfo.Version;
|
||||||
|
|
||||||
|
/// <summary>Commit this build came from — travels with error reports.</summary>
|
||||||
|
public static string GitCommitShort => BuildInfo.GitCommitShort;
|
||||||
|
|
||||||
|
/// <summary>Dashboard grouping of the Watchdog monitor.</summary>
|
||||||
|
public const string MonitorGroup = "Applications";
|
||||||
|
|
||||||
|
/// <summary>Value for the "environment" field of the error stream.</summary>
|
||||||
|
#if DEBUG
|
||||||
|
public const string Environment = "development";
|
||||||
|
#else
|
||||||
|
public const string Environment = "production";
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forwards runtime errors to the Deployment Center error stream
|
||||||
|
/// (POST /api/errors/v1/report). The server groups identical errors, counts them up and
|
||||||
|
/// silences known-harmless ones via ignore rules — so this reports rather than filters.
|
||||||
|
///
|
||||||
|
/// Static on purpose: the Serilog sink and the global exception handlers are wired up
|
||||||
|
/// before the settings are known, and both have to reach the same rate limiter.
|
||||||
|
/// </summary>
|
||||||
|
public static class DcErrorReporter
|
||||||
|
{
|
||||||
|
/// <summary>Server limit is 60 reports per minute and IP — stay well below it.</summary>
|
||||||
|
private const int MaxReportsPerMinute = 20;
|
||||||
|
|
||||||
|
/// <summary>The same error is only reported again after this interval (the server counts it up anyway).</summary>
|
||||||
|
private static readonly TimeSpan RepeatSuppression = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
private static readonly object Sync = new();
|
||||||
|
private static readonly Dictionary<string, DateTime> RecentSignatures = new();
|
||||||
|
|
||||||
|
private static DcApiClient? _api;
|
||||||
|
private static bool _enabled;
|
||||||
|
private static bool _tokenRejected;
|
||||||
|
private static DateTime _windowStartUtc = DateTime.UtcNow;
|
||||||
|
private static int _sentInWindow;
|
||||||
|
|
||||||
|
public static bool IsEnabled => _enabled && _api is not null && !_tokenRejected;
|
||||||
|
|
||||||
|
/// <summary>(Re-)configures the reporter. An empty token switches it off.</summary>
|
||||||
|
public static void Configure(string token, bool enabled)
|
||||||
|
{
|
||||||
|
lock (Sync)
|
||||||
|
{
|
||||||
|
_api?.Dispose();
|
||||||
|
_api = null;
|
||||||
|
_tokenRejected = false;
|
||||||
|
|
||||||
|
if (!enabled || string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
_enabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_api = new DcApiClient(token);
|
||||||
|
_enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Shutdown()
|
||||||
|
{
|
||||||
|
lock (Sync)
|
||||||
|
{
|
||||||
|
_enabled = false;
|
||||||
|
_api?.Dispose();
|
||||||
|
_api = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Installs the global handlers. Without them an unhandled exception ends the process
|
||||||
|
/// without a trace in the error stream — exactly the case the stream exists for.
|
||||||
|
/// </summary>
|
||||||
|
public static void InstallGlobalHandlers()
|
||||||
|
{
|
||||||
|
// Der UI-Thread-Handler bleibt beim Host: jedes Fenstersystem hat sein eigenes
|
||||||
|
// Ereignis dafuer (WinForms Application.ThreadException, Avalonia
|
||||||
|
// Dispatcher.UIThread.UnhandledException). Der Host ruft dafuer
|
||||||
|
// ReportUiThreadException auf.
|
||||||
|
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.ExceptionObject is not Exception ex) return;
|
||||||
|
// Report before logging: the Serilog sink would report the same exception first
|
||||||
|
// and the duplicate suppression would then swallow the blocking call — the
|
||||||
|
// process would die before anything reached the server.
|
||||||
|
Report(ex, "fatal", blocking: true);
|
||||||
|
Log.Fatal(ex, "Unbehandelte Ausnahme — Prozess wird beendet");
|
||||||
|
};
|
||||||
|
|
||||||
|
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||||
|
{
|
||||||
|
Log.Warning(e.Exception, "Unbeobachtete Task-Ausnahme");
|
||||||
|
Report(e.Exception, "error");
|
||||||
|
e.SetObserved();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vom Host aufzurufen, wenn im UI-Thread eine unbehandelte Ausnahme auftritt.
|
||||||
|
/// Protokolliert und meldet sie; die Meldung an den Benutzer bleibt beim Host,
|
||||||
|
/// weil dafuer ein Fenster gebraucht wird.
|
||||||
|
/// </summary>
|
||||||
|
public static void ReportUiThreadException(Exception exception)
|
||||||
|
{
|
||||||
|
Log.Error(exception, "Unbehandelte Ausnahme im UI-Thread");
|
||||||
|
Report(exception, "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Report(Exception exception, string level = "error", bool blocking = false)
|
||||||
|
{
|
||||||
|
if (!IsEnabled) return;
|
||||||
|
|
||||||
|
var inner = Unwrap(exception);
|
||||||
|
var (file, line) = ResolveOrigin(inner);
|
||||||
|
|
||||||
|
Send(
|
||||||
|
exceptionType: inner.GetType().FullName ?? inner.GetType().Name,
|
||||||
|
message: inner.Message,
|
||||||
|
stackTrace: exception.ToString(),
|
||||||
|
level: level,
|
||||||
|
file: file,
|
||||||
|
line: line,
|
||||||
|
blocking: blocking);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reports a logged error that carries no exception (Log.Error("..." )).</summary>
|
||||||
|
public static void ReportMessage(string exceptionType, string message, string? stackTrace, string level)
|
||||||
|
{
|
||||||
|
if (!IsEnabled) return;
|
||||||
|
Send(exceptionType, message, stackTrace, level, null, null, blocking: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Send(
|
||||||
|
string exceptionType, string message, string? stackTrace, string level,
|
||||||
|
string? file, int? line, bool blocking)
|
||||||
|
{
|
||||||
|
DcApiClient api;
|
||||||
|
lock (Sync)
|
||||||
|
{
|
||||||
|
if (_api is null || !_enabled || _tokenRejected) return;
|
||||||
|
if (!PassesRateLimit(exceptionType, message)) return;
|
||||||
|
api = _api;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
project_slug = DcConfig.ProductSlug,
|
||||||
|
exception = exceptionType,
|
||||||
|
message = Truncate(message, 2000),
|
||||||
|
stack_trace = Truncate(stackTrace, 8000),
|
||||||
|
level,
|
||||||
|
build = DcConfig.AppVersion,
|
||||||
|
environment = DcConfig.Environment,
|
||||||
|
file,
|
||||||
|
line,
|
||||||
|
// Tells apart reports coming from several installations of the same build,
|
||||||
|
// and pins the report to an exact commit.
|
||||||
|
context = new { host = System.Environment.MachineName, commit = DcConfig.GitCommitShort }
|
||||||
|
};
|
||||||
|
|
||||||
|
var task = PostAsync(api, payload);
|
||||||
|
if (blocking)
|
||||||
|
{
|
||||||
|
task.Wait(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task PostAsync(DcApiClient api, object payload)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await api.PostJsonAsync("/api/errors/v1/report", payload).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (DcApiException ex) when (ex.IsPermanent)
|
||||||
|
{
|
||||||
|
lock (Sync) { _tokenRejected = true; }
|
||||||
|
// Debug level on purpose: a warning here would be logged, land in the sink and
|
||||||
|
// come straight back as the next report.
|
||||||
|
Log.Debug("Deployment Center error stream rejected the token ({Code}); reporting disabled.", ex.Code);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Debug(ex, "Deployment Center error report failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Local budget: the server counts duplicates itself, we only avoid burning the rate limit.</summary>
|
||||||
|
private static bool PassesRateLimit(string exceptionType, string message)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (now - _windowStartUtc > TimeSpan.FromMinutes(1))
|
||||||
|
{
|
||||||
|
_windowStartUtc = now;
|
||||||
|
_sentInWindow = 0;
|
||||||
|
}
|
||||||
|
if (_sentInWindow >= MaxReportsPerMinute) return false;
|
||||||
|
|
||||||
|
var signature = exceptionType + "|" + Truncate(message, 200);
|
||||||
|
if (RecentSignatures.TryGetValue(signature, out var last) && now - last < RepeatSuppression)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RecentSignatures.Count > 500) RecentSignatures.Clear();
|
||||||
|
RecentSignatures[signature] = now;
|
||||||
|
_sentInWindow++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AggregateException and TargetInvocationException say nothing about the actual defect;
|
||||||
|
/// grouping on them would throw unrelated errors into one bucket.
|
||||||
|
/// </summary>
|
||||||
|
private static Exception Unwrap(Exception exception)
|
||||||
|
{
|
||||||
|
while (exception is AggregateException { InnerExceptions.Count: 1 } aggregate)
|
||||||
|
{
|
||||||
|
exception = aggregate.InnerExceptions[0];
|
||||||
|
}
|
||||||
|
return exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads file and line from the first stack frame that has debug info (PDB present).</summary>
|
||||||
|
private static (string? File, int? Line) ResolveOrigin(Exception exception)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var trace = new StackTrace(exception, fNeedFileInfo: true);
|
||||||
|
foreach (var frame in trace.GetFrames())
|
||||||
|
{
|
||||||
|
var file = frame.GetFileName();
|
||||||
|
if (string.IsNullOrEmpty(file)) continue;
|
||||||
|
var lineNo = frame.GetFileLineNumber();
|
||||||
|
return (ToRepoRelative(file!), lineNo > 0 ? lineNo : (int?)null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Origin is a nicety, never a reason to drop the report.
|
||||||
|
}
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Turns C:\build\...\src\Foo\Bar.cs into src/Foo/Bar.cs so the path matches the repo.</summary>
|
||||||
|
private static string ToRepoRelative(string path)
|
||||||
|
{
|
||||||
|
var normalized = path.Replace('\\', '/');
|
||||||
|
var marker = normalized.LastIndexOf("/src/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
return marker >= 0 ? normalized[(marker + 1)..] : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Truncate(string? value, int max)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)) return value;
|
||||||
|
return value!.Length <= max ? value : value[..max] + "…";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serilog sink that forwards Error and Fatal events to the Deployment Center error stream.
|
||||||
|
///
|
||||||
|
/// It is registered while the logger is being built, long before the settings are known —
|
||||||
|
/// <see cref="DcErrorReporter"/> is asked at emit time whether reporting is switched on, so
|
||||||
|
/// toggling the setting takes effect without rebuilding the logger.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DcErrorSink : ILogEventSink
|
||||||
|
{
|
||||||
|
public void Emit(LogEvent logEvent)
|
||||||
|
{
|
||||||
|
if (logEvent.Level < LogEventLevel.Error) return;
|
||||||
|
if (!DcErrorReporter.IsEnabled) return;
|
||||||
|
|
||||||
|
var level = logEvent.Level == LogEventLevel.Fatal ? "fatal" : "error";
|
||||||
|
|
||||||
|
if (logEvent.Exception is not null)
|
||||||
|
{
|
||||||
|
DcErrorReporter.Report(logEvent.Exception, level);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No exception attached: the rendered message is all the identity this error has.
|
||||||
|
var source = logEvent.Properties.TryGetValue("SourceContext", out var ctx)
|
||||||
|
? ctx.ToString().Trim('"')
|
||||||
|
: "Predictalytics";
|
||||||
|
|
||||||
|
DcErrorReporter.ReportMessage(
|
||||||
|
exceptionType: source,
|
||||||
|
message: logEvent.RenderMessage(),
|
||||||
|
stackTrace: null,
|
||||||
|
level: level);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
|
/// <summary>Single self-assessed health check sent along with a heartbeat.</summary>
|
||||||
|
public sealed class DcCheck
|
||||||
|
{
|
||||||
|
public DcCheck(bool ok, string? message = null, double? value = null)
|
||||||
|
{
|
||||||
|
Ok = ok;
|
||||||
|
Message = message;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonPropertyName("ok")] public bool Ok { get; }
|
||||||
|
[JsonPropertyName("message")] public string? Message { get; }
|
||||||
|
[JsonPropertyName("value")] public double? Value { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the application reports about itself in one heartbeat.</summary>
|
||||||
|
public sealed class DcHeartbeatSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>ok | warning | error — "stopped"/"maintenance" are sent by the service itself.</summary>
|
||||||
|
public string Status { get; set; } = "ok";
|
||||||
|
|
||||||
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
/// <summary>A failing check downgrades an "ok" heartbeat to "warning" on the server.</summary>
|
||||||
|
public Dictionary<string, DcCheck> Checks { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Numeric values; the server keeps 14 days of history per metric.</summary>
|
||||||
|
public Dictionary<string, double> Metrics { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends periodic heartbeats to the Deployment Center Watchdog
|
||||||
|
/// (POST /api/watchdog/v1/ping) so an outage of this app — or of the whole machine —
|
||||||
|
/// raises an alarm. A Deployment Center outage must never impact the app: every call
|
||||||
|
/// is best effort.
|
||||||
|
///
|
||||||
|
/// The server evaluates by interval: no heartbeat for more than 2× the interval means
|
||||||
|
/// "warning", more than 4× means "down".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DcHeartbeatService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DcApiClient _api;
|
||||||
|
private readonly string _source;
|
||||||
|
private readonly string _instance;
|
||||||
|
private readonly int _intervalSeconds;
|
||||||
|
private readonly Func<CancellationToken, Task<DcHeartbeatSnapshot>>? _snapshotProvider;
|
||||||
|
private readonly DateTime _startedUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Guards against overlapping sends when a call takes longer than the interval.
|
||||||
|
private readonly SemaphoreSlim _sendGate = new(1, 1);
|
||||||
|
|
||||||
|
private System.Threading.Timer? _timer;
|
||||||
|
private bool _lastSendFailed;
|
||||||
|
private bool _tokenRejected;
|
||||||
|
private bool _stoppingNotified;
|
||||||
|
private volatile bool _disposed;
|
||||||
|
|
||||||
|
public DcHeartbeatService(
|
||||||
|
string token,
|
||||||
|
string source,
|
||||||
|
string instance,
|
||||||
|
int intervalSeconds,
|
||||||
|
Func<CancellationToken, Task<DcHeartbeatSnapshot>>? snapshotProvider = null)
|
||||||
|
{
|
||||||
|
_api = new DcApiClient(token);
|
||||||
|
_source = string.IsNullOrWhiteSpace(source) ? "Predictalytics" : source;
|
||||||
|
_instance = string.IsNullOrWhiteSpace(instance) ? "default" : instance;
|
||||||
|
_intervalSeconds = Math.Max(15, intervalSeconds);
|
||||||
|
_snapshotProvider = snapshotProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_timer?.Dispose();
|
||||||
|
_timer = new System.Threading.Timer(
|
||||||
|
async _ => await SendHeartbeatAsync().ConfigureAwait(false),
|
||||||
|
null, TimeSpan.Zero, TimeSpan.FromSeconds(_intervalSeconds));
|
||||||
|
Log.Information("🐕 Deployment Center Heartbeat gestartet → {Url} (source={Source}, alle {Interval}s)",
|
||||||
|
DcConfig.BaseUrl, _source, _intervalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Announces a planned shutdown as status "stopped". The evaluator leaves such a monitor
|
||||||
|
/// alone until a normal heartbeat arrives again — without it, every orderly shutdown
|
||||||
|
/// produces a false alarm a few minutes later.
|
||||||
|
/// </summary>
|
||||||
|
public void NotifyStopping()
|
||||||
|
{
|
||||||
|
if (_stoppingNotified) return;
|
||||||
|
_stoppingNotified = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_timer?.Dispose();
|
||||||
|
_timer = null;
|
||||||
|
|
||||||
|
var payload = BuildPayload("stopped", "Predictalytics wird planmäßig beendet.", null, null);
|
||||||
|
// Synchronous with a short cap: the form is closing and must not hang.
|
||||||
|
_api.PostJsonAsync("/api/watchdog/v1/ping", payload).Wait(TimeSpan.FromSeconds(4));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Invoked from a timer callback, i.e. as async void — an exception escaping here would
|
||||||
|
/// take the whole process down. Nothing in this method may throw.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task SendHeartbeatAsync()
|
||||||
|
{
|
||||||
|
if (_disposed || _tokenRejected) return;
|
||||||
|
if (!await _sendGate.WaitAsync(0).ConfigureAwait(false)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_intervalSeconds));
|
||||||
|
|
||||||
|
DcHeartbeatSnapshot snapshot;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
snapshot = _snapshotProvider is null
|
||||||
|
? new DcHeartbeatSnapshot()
|
||||||
|
: await _snapshotProvider(cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Collecting the state must never keep the heartbeat from going out —
|
||||||
|
// that would turn a diagnostic hiccup into a false "down".
|
||||||
|
snapshot = new DcHeartbeatSnapshot { Status = "warning", Message = $"Statusermittlung fehlgeschlagen: {ex.Message}" };
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.Metrics["uptime_sec"] = Math.Round((DateTime.UtcNow - _startedUtc).TotalSeconds);
|
||||||
|
|
||||||
|
var payload = BuildPayload(snapshot.Status, snapshot.Message, snapshot.Checks, snapshot.Metrics);
|
||||||
|
await _api.PostJsonAsync("/api/watchdog/v1/ping", payload, cts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (_lastSendFailed)
|
||||||
|
{
|
||||||
|
_lastSendFailed = false;
|
||||||
|
Log.Information("🐕 Deployment Center Heartbeat wieder erfolgreich zugestellt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (DcApiException ex) when (ex.IsPermanent)
|
||||||
|
{
|
||||||
|
// Retrying cannot help — a rejected token would otherwise log forever.
|
||||||
|
_tokenRejected = true;
|
||||||
|
Log.Warning("🐕 Deployment Center weist das Token zurück ({Code}) — Heartbeats werden eingestellt. " +
|
||||||
|
"Bitte in den Settings ein Token mit dem Recht 'watchdog:ping' eintragen.", ex.Code ?? "unauthorized");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Log the first failure as warning, subsequent ones quietly (no log flood).
|
||||||
|
if (!_lastSendFailed)
|
||||||
|
{
|
||||||
|
_lastSendFailed = true;
|
||||||
|
Log.Warning("🐕 Deployment Center Heartbeat fehlgeschlagen (weitere Fehler werden unterdrückt): {Error}", ex.Message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.Debug(ex, "Deployment Center heartbeat failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_sendGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_disposed = true;
|
||||||
|
_timer?.Dispose();
|
||||||
|
_timer = null;
|
||||||
|
|
||||||
|
// Give a send in flight a moment to finish before the HttpClient goes away. Called
|
||||||
|
// from the UI thread (settings change, form closing), so the wait stays short — if it
|
||||||
|
// expires, the pending call just fails and gets logged like any other network error.
|
||||||
|
if (_sendGate.Wait(TimeSpan.FromMilliseconds(250)))
|
||||||
|
{
|
||||||
|
_sendGate.Release();
|
||||||
|
}
|
||||||
|
_api.Dispose();
|
||||||
|
|
||||||
|
// _sendGate is deliberately not disposed: a late Release() on a disposed semaphore
|
||||||
|
// would throw on the timer thread for no gain — SemaphoreSlim without a wait handle
|
||||||
|
// holds no unmanaged resources.
|
||||||
|
}
|
||||||
|
|
||||||
|
private object BuildPayload(
|
||||||
|
string status,
|
||||||
|
string? message,
|
||||||
|
Dictionary<string, DcCheck>? checks,
|
||||||
|
Dictionary<string, double>? metrics) => new
|
||||||
|
{
|
||||||
|
source = _source,
|
||||||
|
instance = _instance,
|
||||||
|
type = "heartbeat",
|
||||||
|
status,
|
||||||
|
interval = _intervalSeconds,
|
||||||
|
message,
|
||||||
|
group = DcConfig.MonitorGroup,
|
||||||
|
os = $"{RuntimeInformation.OSDescription} / .NET {System.Environment.Version}",
|
||||||
|
// Since 2.1 the monitor can show which build is running — that is what ties
|
||||||
|
// "this monitor went down" to "we rolled out 1.4.3 an hour ago".
|
||||||
|
version = DcConfig.AppVersion,
|
||||||
|
checks = checks is { Count: > 0 } ? checks : null,
|
||||||
|
metrics = metrics is { Count: > 0 } ? metrics : null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Deploymentcenter.Client;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the Deployment Center UpdateService for a newer release
|
||||||
|
/// (GET /api/updateservice/v1/check, no token needed).
|
||||||
|
///
|
||||||
|
/// The check itself only reads metadata. Installing is done by the standalone
|
||||||
|
/// update-agent, which replaces the running installation and therefore has to be
|
||||||
|
/// started explicitly by the user.
|
||||||
|
/// </summary>
|
||||||
|
public static class DcUpdateService
|
||||||
|
{
|
||||||
|
private const string AgentFileName = "update-agent.exe";
|
||||||
|
|
||||||
|
public static async Task<UpdateCheckResult> CheckAsync(string channel, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var client = new UpdateClient();
|
||||||
|
return await client.CheckForUpdateAsync(
|
||||||
|
baseUrl: DcConfig.BaseUrl,
|
||||||
|
projectId: DcConfig.ProductSlug,
|
||||||
|
currentVersion: DcConfig.AppVersion,
|
||||||
|
channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel,
|
||||||
|
cancellationToken: ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Path of the update agent next to the exe, or null if it was not deployed.</summary>
|
||||||
|
public static string? FindUpdateAgent()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(AppContext.BaseDirectory, AgentFileName);
|
||||||
|
return File.Exists(path) ? path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hands control to the update agent and ends this process. Returns false if the agent
|
||||||
|
/// is not present — then the update has to be installed by hand.
|
||||||
|
/// </summary>
|
||||||
|
public static bool LaunchAgent(string channel)
|
||||||
|
{
|
||||||
|
var agent = FindUpdateAgent();
|
||||||
|
if (agent is null)
|
||||||
|
{
|
||||||
|
Log.Warning("Update-Agent ({Agent}) liegt nicht neben der Anwendung — Update bitte manuell einspielen.", AgentFileName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("Starte Update-Agent {Agent} (Kanal {Channel}) und beende die Anwendung...", agent, channel);
|
||||||
|
return UpdateClient.LaunchUpdateAgent(
|
||||||
|
agentPath: agent,
|
||||||
|
projectId: DcConfig.ProductSlug,
|
||||||
|
channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel,
|
||||||
|
action: "update",
|
||||||
|
version: "latest",
|
||||||
|
exitCurrentApp: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,94 +3,105 @@ using Serilog;
|
|||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
|
/// <summary>An activated license: the key belonging to this machine plus the last result.</summary>
|
||||||
|
public sealed class LicenseSession
|
||||||
|
{
|
||||||
|
public LicenseSession(LicenseClient client, string licenseKey, LicenseValidationResult result)
|
||||||
|
{
|
||||||
|
Client = client;
|
||||||
|
LicenseKey = licenseKey;
|
||||||
|
LastResult = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LicenseClient Client { get; }
|
||||||
|
public string LicenseKey { get; }
|
||||||
|
public LicenseValidationResult LastResult { get; internal set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ergebnis der Pruefung des zwischengespeicherten Schluessels.</summary>
|
||||||
|
/// <param name="Session">Gesetzt, wenn eine nutzbare Lizenz vorliegt.</param>
|
||||||
|
/// <param name="LastResult">Letzte Serverantwort — null, wenn gar kein Schluessel hinterlegt war.</param>
|
||||||
|
public readonly record struct CachedLicenseCheck(LicenseSession? Session, LicenseValidationResult? LastResult)
|
||||||
|
{
|
||||||
|
public bool IsUsable => Session is not null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lizenzpruefung gegen das Deploymentcenter (dc.mhdf.de).
|
/// Startup license gate backed by the Deployment Center (POST /api/license/v1/validate).
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Endpunkt und Produkt-Slug sind bewusst einkompiliert (keine Benutzer-
|
/// Endpoint and product slug are deliberately compiled in (see <see cref="DcConfig"/>):
|
||||||
/// konfiguration): ein konfigurierbarer Endpunkt wuerde es erlauben, die
|
/// a configurable endpoint would let anyone point the app at a fake license server.
|
||||||
/// Anwendung auf einen gefaelschten Lizenzserver zu zeigen.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Enthaelt keinen Dialog: die interaktive Aktivierung liegt beim Host
|
/// Enthaelt bewusst keinen Dialog: die interaktive Aktivierung liegt beim Host
|
||||||
/// (Avalonia- bzw. WinForms-Fenster), der Headless-Modus bezieht den
|
/// (Avalonia-Fenster), der Headless-Modus bezieht den Schluessel aus der
|
||||||
/// Schluessel aus der Umgebungsvariable oder dem verschluesselten Cache.
|
/// Umgebungsvariable oder dem verschluesselten Cache.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class LicenseGuard
|
public static class LicenseGuard
|
||||||
{
|
{
|
||||||
/// <summary>In dc_projects hinterlegter Slug.</summary>
|
|
||||||
public const string ProductSlug = "predictalytics";
|
|
||||||
|
|
||||||
private const string ServerUrl = "https://dc.mhdf.de";
|
|
||||||
|
|
||||||
/// <summary>Re-check interval while the app is running (12 h).</summary>
|
/// <summary>Re-check interval while the app is running (12 h).</summary>
|
||||||
public static readonly TimeSpan RevalidationInterval = TimeSpan.FromHours(12);
|
public static readonly TimeSpan RevalidationInterval = TimeSpan.FromHours(12);
|
||||||
|
|
||||||
private static readonly LicenseClient Client = new();
|
public static LicenseClient CreateClient() => new();
|
||||||
|
|
||||||
/// <summary>Hardware-ID v2 dieser Maschine (Format <c>2:<plattform>:<hex></c>).</summary>
|
/// <summary>Hardware ID v2 of this machine — shown in the dialog and needed for support.</summary>
|
||||||
public static HardwareIdResult GetHardwareInfo() => HardwareId.GetHardwareId(ProductSlug);
|
public static HardwareIdResult GetHardwareInfo() => HardwareId.GetHardwareId(DcConfig.ProductSlug);
|
||||||
|
|
||||||
/// <summary>Ablageort des verschluesselten Lizenz-Caches.</summary>
|
/// <summary>Ablageort des verschluesselten Lizenz-Caches.</summary>
|
||||||
public static string StorageDirectory => LicenseConfig.GetStorageDirectory(ProductSlug);
|
public static string StorageDirectory => LicenseConfig.GetStorageDirectory(DcConfig.ProductSlug);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Zwischengespeicherter Lizenzschluessel, oder null wenn noch nie aktiviert wurde.
|
/// Prueft den Schluessel der letzten erfolgreichen Aktivierung aus dem verschluesselten
|
||||||
/// Der Cache ist AES-GCM-verschluesselt und an Hardware-ID und Produkt gebunden.
|
/// Cache. Der Host entscheidet, was bei einem negativen Ergebnis passiert — Dialog
|
||||||
|
/// anzeigen (GUI) oder mit Fehlercode beenden (headless).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string? GetCachedLicenseKey()
|
public static async Task<CachedLicenseCheck> TryUseCachedAsync(LicenseClient? client = null)
|
||||||
{
|
{
|
||||||
try
|
client ??= CreateClient();
|
||||||
|
var hardware = GetHardwareInfo();
|
||||||
|
|
||||||
|
// The client takes the key as a parameter on every call; the key of the last
|
||||||
|
// successful activation lives in the encrypted local cache.
|
||||||
|
var cachedKey = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
|
||||||
|
if (string.IsNullOrWhiteSpace(cachedKey))
|
||||||
{
|
{
|
||||||
var cache = StateStore.Load(ProductSlug, GetHardwareInfo().HardwareId);
|
return new CachedLicenseCheck(null, null);
|
||||||
return string.IsNullOrWhiteSpace(cache?.LicenseKey) ? null : cache!.LicenseKey;
|
|
||||||
}
|
}
|
||||||
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");
|
Log.Information("🔑 Lizenz geprüft: {Status}{Cached} (HWID {Hwid}, Quelle {Source})",
|
||||||
return null;
|
result.Status, result.IsCached ? " — aus Offline-Cache" : "",
|
||||||
|
hardware.HardwareId, hardware.HwidSource);
|
||||||
|
WarnIfGraceRunningOut(result);
|
||||||
|
return new CachedLicenseCheck(new LicenseSession(client, cachedKey!, result), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.Warning("🔑 Gespeicherter Lizenzschlüssel nicht nutzbar ({Status}): {Message}",
|
||||||
|
result.Status, result.Message);
|
||||||
|
return new CachedLicenseCheck(null, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Aktiviert einen neu eingegebenen Schluessel.</summary>
|
||||||
/// Validiert einen Lizenzschluessel am Server und legt ihn bei Erfolg
|
public static async Task<(LicenseSession? Session, LicenseValidationResult Result)> ActivateAsync(
|
||||||
/// verschluesselt ab. Bei Netzwerkfehlern faellt der Client auf den
|
LicenseClient client, string licenseKey)
|
||||||
/// Cache zurueck (Status <c>valid_offline</c>).
|
|
||||||
/// </summary>
|
|
||||||
public static Task<LicenseValidationResult> ValidateAsync(string licenseKey) =>
|
|
||||||
Client.ValidateAsync(ProductSlug, Normalize(licenseKey), ServerUrl);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validiert mit dem zwischengespeicherten Schluessel.
|
|
||||||
/// Liefert null, wenn noch keiner hinterlegt ist.
|
|
||||||
/// </summary>
|
|
||||||
public static async Task<LicenseValidationResult?> ValidateCachedAsync()
|
|
||||||
{
|
{
|
||||||
var key = GetCachedLicenseKey();
|
var key = licenseKey.Trim().ToUpperInvariant();
|
||||||
return key == null ? null : await ValidateAsync(key);
|
var result = await client.ValidateAsync(DcConfig.ProductSlug, key, DcConfig.BaseUrl);
|
||||||
|
return result.IsValid
|
||||||
|
? (new LicenseSession(client, key, result), result)
|
||||||
|
: (null, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gibt den Aktivierungsplatz dieser Maschine am Server wieder frei.</summary>
|
|
||||||
public static async Task<bool> DeactivateAsync()
|
|
||||||
{
|
|
||||||
var key = GetCachedLicenseKey();
|
|
||||||
if (key == null) return false;
|
|
||||||
return await Client.DeactivateAsync(ProductSlug, key, ServerUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Schreibweise vereinheitlichen — die Schluessel sind durchgaengig gross.</summary>
|
|
||||||
public static string Normalize(string licenseKey) => licenseKey.Trim().ToUpperInvariant();
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Startet die periodische Revalidierung im Hintergrund. Erkennt Widerruf und
|
/// Starts the periodic in-app revalidation. Detects revocation/expiry while the app keeps
|
||||||
/// Ablauf waehrend des Betriebs.
|
/// running; only a definitive negative triggers <paramref name="onUnusable"/> — a server
|
||||||
|
/// outage must not.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="onUnusable">
|
public static IDisposable StartPeriodicRevalidation(
|
||||||
/// Wird genau einmal aufgerufen, wenn die Lizenz endgueltig nicht mehr nutzbar ist.
|
LicenseSession session, Action<LicenseValidationResult> onUnusable)
|
||||||
/// Der Host entscheidet, was dann passiert. Netzwerkfehler loesen den Rueckruf
|
|
||||||
/// nicht aus — dafuer gibt es den verschluesselten Offline-Cache des Clients.
|
|
||||||
/// </param>
|
|
||||||
public static IDisposable StartPeriodicRevalidation(Action<LicenseValidationResult> onUnusable)
|
|
||||||
{
|
{
|
||||||
var cts = new CancellationTokenSource();
|
var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
@@ -103,28 +114,37 @@ public static class LicenseGuard
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await ValidateCachedAsync();
|
var result = await session.Client.ValidateAsync(
|
||||||
if (result == null)
|
DcConfig.ProductSlug, session.LicenseKey, DcConfig.BaseUrl);
|
||||||
{
|
session.LastResult = result;
|
||||||
Log.Warning("Lizenz-Revalidierung: kein zwischengespeicherter Schlüssel vorhanden.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.IsValid)
|
if (result.IsValid)
|
||||||
{
|
{
|
||||||
if (result.IsCached)
|
if (result.IsCached)
|
||||||
{
|
{
|
||||||
Log.Warning("Lizenzserver nicht erreichbar — Lizenz gilt über den lokalen Cache weiter ({Status}).",
|
Log.Warning("Lizenzserver nicht erreichbar — Prüfung erfolgte aus dem Offline-Cache.");
|
||||||
result.Status);
|
|
||||||
}
|
}
|
||||||
|
WarnIfGraceRunningOut(result);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// network_error heisst: Server weg UND kein brauchbarer Cache.
|
if (result.IsTransient)
|
||||||
// Das ist kein Widerruf — beim naechsten Durchlauf erneut versuchen.
|
|
||||||
if (result.Status.Equals("network_error", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +155,7 @@ public static class LicenseGuard
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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).");
|
Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +169,22 @@ public static class LicenseGuard
|
|||||||
return new Stopper(cts);
|
return new Stopper(cts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Since 2.1 the offline grace period is really bounded (cache_ttl_hours, default 168 h).
|
||||||
|
/// A machine that is offline on purpose should learn that before it runs out, not after.
|
||||||
|
/// </summary>
|
||||||
|
private static void WarnIfGraceRunningOut(LicenseValidationResult result)
|
||||||
|
{
|
||||||
|
if (result.CacheExpiresAt is not { } expiresAt || expiresAt <= 0) return;
|
||||||
|
|
||||||
|
var remaining = DateTimeOffset.FromUnixTimeSeconds(expiresAt) - DateTimeOffset.UtcNow;
|
||||||
|
if (remaining > TimeSpan.FromHours(48)) return;
|
||||||
|
|
||||||
|
Log.Warning("🔑 Offline-Gnadenfrist endet in {Hours:F0} h ({Until:yyyy-MM-dd HH:mm} UTC) — " +
|
||||||
|
"bis dahin muss das Deployment Center einmal erreichbar sein.",
|
||||||
|
Math.Max(0, remaining.TotalHours), DateTimeOffset.FromUnixTimeSeconds(expiresAt).UtcDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class Stopper(CancellationTokenSource cts) : IDisposable
|
private sealed class Stopper(CancellationTokenSource cts) : IDisposable
|
||||||
{
|
{
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ public static class LoggingSetup
|
|||||||
.Filter.ByExcluding(e => e.Exception != null && e.Exception.ToString().Contains("Duplicate entry"))
|
.Filter.ByExcluding(e => e.Exception != null && e.Exception.ToString().Contains("Duplicate entry"))
|
||||||
|
|
||||||
// ── Console (simple) ──
|
// ── Console (simple) ──
|
||||||
.WriteTo.Console(outputTemplate: SimpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning);
|
.WriteTo.Console(outputTemplate: SimpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning)
|
||||||
|
|
||||||
|
// ── Deployment Center error stream (Error/Fatal) ──
|
||||||
|
// 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) ──
|
// ── Terminalanzeige des Hosts (optional) ──
|
||||||
if (terminalSink != null)
|
if (terminalSink != null)
|
||||||
@@ -152,7 +157,7 @@ public static class LoggingSetup
|
|||||||
public static void LogStartupBanner()
|
public static void LogStartupBanner()
|
||||||
{
|
{
|
||||||
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("══════════════════════════════════════════════════════");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<RootNamespace>Predictalytics.Hosting</RootNamespace>
|
<RootNamespace>Predictalytics.Hosting</RootNamespace>
|
||||||
|
<!-- Wird als current_version an den UpdateService und als build an den Fehler-Stream
|
||||||
|
gemeldet. Beim Release hier hochziehen. -->
|
||||||
|
<Version>1.0.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -29,8 +32,12 @@
|
|||||||
<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\Deploymentcenter muss neben dem Predictalytics-Checkout liegen.
|
<!-- Externes Schwester-Repo: J:\Softwareprojekte\Deploymentcenter muss neben dem Predictalytics-Checkout liegen.
|
||||||
Loest LicenseLabrador ab (license.mhdf.de -> dc.mhdf.de). -->
|
Loest Watchdog + LicenseLabrador ab (Lizenz, UpdateService, Fehler-Stream, Bugtracker). -->
|
||||||
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Erzeugt Predictalytics.Hosting.BuildInfo (Version, Git-Commit, Build-Datum, Kanal)
|
||||||
|
zur Uebersetzungszeit aus <Version> und dem Git-Stand. -->
|
||||||
|
<Import Project="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ namespace Predictalytics.Hosting;
|
|||||||
/// Laufzeit-Konfiguration der Anwendung.
|
/// Laufzeit-Konfiguration der Anwendung.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Die <see cref="System.ComponentModel"/>-Attribute sind plattformneutral (Teil der
|
/// Die <see cref="System.ComponentModel"/>-Attribute sind plattformneutral (Teil der
|
||||||
/// Basisbibliothek) und werden vom WinForms-PropertyGrid ausgewertet. Die Avalonia-Shell
|
/// Basisbibliothek). Die Avalonia-Shell nutzt sie fuer Beschriftungen und Hilfetexte
|
||||||
/// kann sie fuer generierte Beschriftungen nutzen oder ignorieren.
|
/// der Einstellungsansicht.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PredictalyticsOptions
|
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")]
|
"Beispiel: prox-1|Proxy|http://user:pass@proxy:8080\nip-1|SourceIp|192.168.1.100")]
|
||||||
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
|
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
|
||||||
|
|
||||||
// ─── Watchdog ───
|
// ─── Deployment Center ───
|
||||||
|
|
||||||
[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 => 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";
|
||||||
|
|
||||||
// ─── Database ───
|
// ─── Database ───
|
||||||
|
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sends periodic dead-man's-switch heartbeats to the external Watchdog server
|
|
||||||
/// (POST /api/heartbeat) so an outage of this app — or the whole machine — raises
|
|
||||||
/// an alarm. A Watchdog outage must never impact the app: every call is best effort.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class WatchdogHeartbeatService : IDisposable
|
|
||||||
{
|
|
||||||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
|
|
||||||
private readonly string _baseUrl;
|
|
||||||
private readonly string _apiKey;
|
|
||||||
private readonly string _source;
|
|
||||||
private readonly string _instance;
|
|
||||||
private readonly int _intervalSeconds;
|
|
||||||
private readonly Func<object?>? _metadataProvider;
|
|
||||||
private readonly DateTime _startedUtc = DateTime.UtcNow;
|
|
||||||
|
|
||||||
private System.Threading.Timer? _timer;
|
|
||||||
private bool _lastSendFailed;
|
|
||||||
|
|
||||||
public WatchdogHeartbeatService(
|
|
||||||
string baseUrl,
|
|
||||||
string apiKey,
|
|
||||||
string source,
|
|
||||||
string instance,
|
|
||||||
int intervalSeconds,
|
|
||||||
Func<object?>? metadataProvider = null)
|
|
||||||
{
|
|
||||||
_baseUrl = baseUrl.TrimEnd('/');
|
|
||||||
_apiKey = apiKey;
|
|
||||||
_source = source;
|
|
||||||
_instance = string.IsNullOrWhiteSpace(instance) ? "default" : instance;
|
|
||||||
_intervalSeconds = Math.Max(15, intervalSeconds);
|
|
||||||
_metadataProvider = metadataProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
_timer?.Dispose();
|
|
||||||
_timer = new System.Threading.Timer(
|
|
||||||
async _ => await SendHeartbeatAsync("ok"),
|
|
||||||
null, TimeSpan.Zero, TimeSpan.FromSeconds(_intervalSeconds));
|
|
||||||
Log.Information("🐕 Watchdog heartbeat started → {Url} (source={Source}, every {Interval}s)",
|
|
||||||
_baseUrl, _source, _intervalSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reports a planned shutdown so it is not alarmed as a crash.
|
|
||||||
/// Must be "stopped_graceful": the server's event_log.kind ENUM has no "stopping"
|
|
||||||
/// value, and that variant fails the insert with HTTP 500 after the state update.
|
|
||||||
/// </summary>
|
|
||||||
public void NotifyStopping()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var payload = new
|
|
||||||
{
|
|
||||||
source = _source,
|
|
||||||
instance = _instance,
|
|
||||||
kind = "stopped_graceful",
|
|
||||||
severity = "info",
|
|
||||||
message = "Predictalytics wird planmäßig beendet."
|
|
||||||
};
|
|
||||||
// Synchronous with a short cap: the form is closing and must not hang.
|
|
||||||
PostAsync("/api/event", payload).Wait(TimeSpan.FromSeconds(4));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Best effort only.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SendHeartbeatAsync(string status)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var payload = new
|
|
||||||
{
|
|
||||||
source = _source,
|
|
||||||
instance = _instance,
|
|
||||||
type = "heartbeat",
|
|
||||||
status,
|
|
||||||
message = (string?)null,
|
|
||||||
metrics = new
|
|
||||||
{
|
|
||||||
uptimeSec = (long)(DateTime.UtcNow - _startedUtc).TotalSeconds,
|
|
||||||
app = _metadataProvider?.Invoke()
|
|
||||||
},
|
|
||||||
group = "C# Applications",
|
|
||||||
interval = _intervalSeconds
|
|
||||||
};
|
|
||||||
|
|
||||||
await PostAsync("/api/heartbeat", payload);
|
|
||||||
|
|
||||||
if (_lastSendFailed)
|
|
||||||
{
|
|
||||||
_lastSendFailed = false;
|
|
||||||
Log.Information("🐕 Watchdog heartbeat wieder erfolgreich zugestellt.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Log the first failure as warning, subsequent ones quietly (no log flood).
|
|
||||||
if (!_lastSendFailed)
|
|
||||||
{
|
|
||||||
_lastSendFailed = true;
|
|
||||||
Log.Warning("🐕 Watchdog heartbeat fehlgeschlagen (weitere Fehler werden unterdrückt): {Error}", ex.Message);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Log.Debug(ex, "Watchdog heartbeat failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PostAsync(string path, object payload)
|
|
||||||
{
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path);
|
|
||||||
request.Headers.Add("X-Watchdog-Key", _apiKey);
|
|
||||||
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
|
||||||
|
|
||||||
using var response = await _http.SendAsync(request);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var body = await response.Content.ReadAsStringAsync();
|
|
||||||
throw new HttpRequestException($"Watchdog API HTTP {(int)response.StatusCode}: {body}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_timer?.Dispose();
|
|
||||||
_timer = null;
|
|
||||||
_http.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,31 +42,50 @@ public partial class App : global::Avalonia.Application
|
|||||||
|
|
||||||
private async Task StartupAsync(IClassicDesktopStyleApplicationLifetime desktop)
|
private async Task StartupAsync(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
// ─── Lizenzschranke: ohne nutzbare Lizenz keine Anwendung ───
|
// Einstellungen zuerst: das Fehler-Reporting braucht das Token, bevor die
|
||||||
var result = await LicenseGuard.ValidateCachedAsync();
|
// 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;
|
desktop.MainWindow = licenseWindow;
|
||||||
licenseWindow.Show();
|
licenseWindow.Show();
|
||||||
|
|
||||||
if (!await licenseWindow.Completion)
|
session = await licenseWindow.Completion;
|
||||||
|
if (session is null)
|
||||||
{
|
{
|
||||||
desktop.Shutdown();
|
desktop.Shutdown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewModel.License = session;
|
||||||
|
|
||||||
// ─── Hauptfenster ───
|
// ─── 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.MainWindow = window;
|
||||||
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
|
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||||
window.Show();
|
window.Show();
|
||||||
@@ -74,7 +93,7 @@ public partial class App : global::Avalonia.Application
|
|||||||
viewModel.Start();
|
viewModel.Start();
|
||||||
|
|
||||||
// Waehrend des Betriebs alle 12 h nachpruefen (Widerruf, Ablauf, Offline-Frist).
|
// Waehrend des Betriebs alle 12 h nachpruefen (Widerruf, Ablauf, Offline-Frist).
|
||||||
_licenseWatch = LicenseGuard.StartPeriodicRevalidation(unusable =>
|
_licenseWatch = LicenseGuard.StartPeriodicRevalidation(session, unusable =>
|
||||||
Dispatcher.UIThread.Post(async () =>
|
Dispatcher.UIThread.Post(async () =>
|
||||||
{
|
{
|
||||||
await Dialogs.ShowErrorAsync(window, "Lizenzfehler",
|
await Dialogs.ShowErrorAsync(window, "Lizenzfehler",
|
||||||
@@ -88,4 +107,17 @@ public partial class App : global::Avalonia.Application
|
|||||||
Log.CloseAndFlush();
|
Log.CloseAndFlush();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gegenstueck zu <c>Application.ThreadException</c> in WinForms: eine im UI-Thread
|
||||||
|
/// durchgereichte Ausnahme wuerde den Prozess sonst spurlos beenden.
|
||||||
|
/// </summary>
|
||||||
|
private static void InstallUiThreadHandler()
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.UnhandledException += (_, e) =>
|
||||||
|
{
|
||||||
|
DcErrorReporter.ReportUiThreadException(e.Exception);
|
||||||
|
e.Handled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,25 +24,32 @@ internal static class HeadlessRunner
|
|||||||
|
|
||||||
// Kein Terminal-Sink: Ausgabe geht auf die Konsole und in die Logdateien.
|
// Kein Terminal-Sink: Ausgabe geht auf die Konsole und in die Logdateien.
|
||||||
LoggingSetup.Configure();
|
LoggingSetup.Configure();
|
||||||
|
|
||||||
|
DcErrorReporter.Configure(options.DcToken, options.DcErrorReportingEnabled);
|
||||||
|
DcErrorReporter.InstallGlobalHandlers();
|
||||||
|
Deploymentcenter.Client.LicenseClient.DefaultAppVersion = DcConfig.AppVersion;
|
||||||
|
|
||||||
LoggingSetup.LogStartupBanner();
|
LoggingSetup.LogStartupBanner();
|
||||||
Log.Information("Headless-Modus. Einstellungen: {Path}", PredictalyticsOptions.SettingsFilePath);
|
Log.Information("Headless-Modus. Einstellungen: {Path}", PredictalyticsOptions.SettingsFilePath);
|
||||||
|
|
||||||
|
DcHeartbeatService? heartbeat = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!await EnsureLicensedAsync()) return ExitNoLicense;
|
var session = await EnsureLicensedAsync();
|
||||||
|
if (session is null) return ExitNoLicense;
|
||||||
|
|
||||||
var host = new PredictalyticsHost(options);
|
var host = new PredictalyticsHost(options);
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource();
|
||||||
using var shutdownSignals = RegisterShutdown(cts);
|
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.",
|
Log.Fatal("Lizenz nicht mehr gültig ({Status}): {Message} — Dienst wird beendet.",
|
||||||
result.Status, result.Message);
|
result.Status, result.Message);
|
||||||
cts.Cancel();
|
cts.Cancel();
|
||||||
});
|
});
|
||||||
|
|
||||||
using var watchdog = StartWatchdog(options, host);
|
heartbeat = StartHeartbeat(options, host);
|
||||||
|
|
||||||
await host.StartWebServerAsync();
|
await host.StartWebServerAsync();
|
||||||
Log.Information("WebUI erreichbar unter {Url}", options.WebserverUrl);
|
Log.Information("WebUI erreichbar unter {Url}", options.WebserverUrl);
|
||||||
@@ -50,13 +57,14 @@ internal static class HeadlessRunner
|
|||||||
// Laeuft, bis abgebrochen wird.
|
// Laeuft, bis abgebrochen wird.
|
||||||
await host.StartWorkersAsync(cts.Token);
|
await host.StartWorkersAsync(cts.Token);
|
||||||
|
|
||||||
watchdog?.NotifyStopping();
|
heartbeat?.NotifyStopping();
|
||||||
await host.StopWebServerAsync();
|
await host.StopWebServerAsync();
|
||||||
Log.Information("Dienst planmäßig beendet.");
|
Log.Information("Dienst planmäßig beendet.");
|
||||||
return ExitOk;
|
return ExitOk;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
heartbeat?.NotifyStopping();
|
||||||
Log.Information("Dienst planmäßig beendet.");
|
Log.Information("Dienst planmäßig beendet.");
|
||||||
return ExitOk;
|
return ExitOk;
|
||||||
}
|
}
|
||||||
@@ -67,6 +75,7 @@ internal static class HeadlessRunner
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
heartbeat?.Dispose();
|
||||||
await Log.CloseAndFlushAsync();
|
await Log.CloseAndFlushAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,15 +84,12 @@ internal static class HeadlessRunner
|
|||||||
/// Lizenzpruefung ohne Dialog: zuerst der zwischengespeicherte Schluessel, sonst
|
/// Lizenzpruefung ohne Dialog: zuerst der zwischengespeicherte Schluessel, sonst
|
||||||
/// einmalige Aktivierung mit dem Schluessel aus der Umgebungsvariable.
|
/// einmalige Aktivierung mit dem Schluessel aus der Umgebungsvariable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<bool> EnsureLicensedAsync()
|
private static async Task<LicenseSession?> EnsureLicensedAsync()
|
||||||
{
|
{
|
||||||
var result = await LicenseGuard.ValidateCachedAsync();
|
var client = LicenseGuard.CreateClient();
|
||||||
if (result?.IsValid == true)
|
|
||||||
{
|
var check = await LicenseGuard.TryUseCachedAsync(client);
|
||||||
Log.Information("Lizenz gültig ({Status}{Cached}).",
|
if (check.Session is not null) return check.Session;
|
||||||
result.Status, result.IsCached ? ", aus lokalem Cache" : "");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var key = Environment.GetEnvironmentVariable(LicenseKeyVariable);
|
var key = Environment.GetEnvironmentVariable(LicenseKeyVariable);
|
||||||
if (string.IsNullOrWhiteSpace(key))
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
@@ -91,44 +97,76 @@ internal static class HeadlessRunner
|
|||||||
Log.Fatal("Keine nutzbare Lizenz ({Status}) und {Variable} ist nicht gesetzt. " +
|
Log.Fatal("Keine nutzbare Lizenz ({Status}) und {Variable} ist nicht gesetzt. " +
|
||||||
"Im Headless-Betrieb gibt es keinen Aktivierungsdialog — bitte den Lizenzschlüssel " +
|
"Im Headless-Betrieb gibt es keinen Aktivierungsdialog — bitte den Lizenzschlüssel " +
|
||||||
"über die Umgebungsvariable bereitstellen oder einmalig mit --license-set-key aktivieren.",
|
"über die Umgebungsvariable bereitstellen oder einmalig mit --license-set-key aktivieren.",
|
||||||
result?.Status ?? "kein Schlüssel hinterlegt", LicenseKeyVariable);
|
check.LastResult?.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.");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var watchdog = new WatchdogHeartbeatService(
|
Log.Information("Aktiviere Lizenz mit dem Schlüssel aus {Variable}…", LicenseKeyVariable);
|
||||||
options.WatchdogUrl,
|
var (session, result) = await LicenseGuard.ActivateAsync(client, key);
|
||||||
options.WatchdogApiKey,
|
if (session is not null)
|
||||||
options.WatchdogSource,
|
{
|
||||||
options.WatchdogInstance,
|
Log.Information("Lizenz aktiviert ({Status}).", result.Status);
|
||||||
options.WatchdogIntervalSeconds,
|
return session;
|
||||||
metadataProvider: () => new
|
}
|
||||||
|
|
||||||
|
Log.Fatal("Aktivierung fehlgeschlagen ({Status}): {Message}", result.Status, result.Message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DcHeartbeatService? StartHeartbeat(PredictalyticsOptions options, PredictalyticsHost host)
|
||||||
|
{
|
||||||
|
if (!options.DcHeartbeatEnabled) return null;
|
||||||
|
if (string.IsNullOrWhiteSpace(options.DcToken))
|
||||||
|
{
|
||||||
|
Log.Information("🐕 Heartbeat ist aktiviert, aber es fehlt das Deployment-Center-Token.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var heartbeat = new DcHeartbeatService(
|
||||||
|
options.DcToken,
|
||||||
|
options.DcSource,
|
||||||
|
options.DcInstance,
|
||||||
|
options.DcHeartbeatIntervalSeconds,
|
||||||
|
ct => CollectSnapshotAsync(options, host, ct));
|
||||||
|
heartbeat.Start();
|
||||||
|
return heartbeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<DcHeartbeatSnapshot> CollectSnapshotAsync(
|
||||||
|
PredictalyticsOptions options, PredictalyticsHost host, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var snapshot = new DcHeartbeatSnapshot
|
||||||
|
{
|
||||||
|
Message = host.WorkersRunning ? "Worker laufen" : "Worker gestoppt"
|
||||||
|
};
|
||||||
|
|
||||||
|
snapshot.Metrics["workers_running"] = host.WorkersRunning ? 1 : 0;
|
||||||
|
snapshot.Metrics["webserver_running"] = host.WebServerRunning ? 1 : 0;
|
||||||
|
snapshot.Metrics["memory_mb"] = Math.Round(GC.GetTotalMemory(forceFullCollection: false) / 1024d / 1024d, 1);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.DbName))
|
||||||
|
{
|
||||||
|
var started = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
workersRunning = host.WorkersRunning,
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
webserverRunning = host.WebServerRunning
|
timeout.CancelAfter(TimeSpan.FromSeconds(5));
|
||||||
});
|
|
||||||
watchdog.Start();
|
await using var conn = new MySqlConnector.MySqlConnection(options.ConnectionString);
|
||||||
return watchdog;
|
await conn.OpenAsync(timeout.Token);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT 1";
|
||||||
|
await cmd.ExecuteScalarAsync(timeout.Token);
|
||||||
|
|
||||||
|
snapshot.Checks["db"] = new DcCheck(true, $"{started.ElapsedMilliseconds} ms", started.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
snapshot.Checks["db"] = new DcCheck(false, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Deploymentcenter.Client;
|
||||||
using Predictalytics.Hosting;
|
using Predictalytics.Hosting;
|
||||||
|
|
||||||
namespace Predictalytics.Shell.Services;
|
namespace Predictalytics.Shell.Services;
|
||||||
@@ -18,26 +19,30 @@ internal static class LicenseCli
|
|||||||
|
|
||||||
public static async Task<int> RunAsync(string[] args)
|
public static async Task<int> RunAsync(string[] args)
|
||||||
{
|
{
|
||||||
switch (args[0])
|
LicenseClient.DefaultAppVersion = DcConfig.AppVersion;
|
||||||
|
|
||||||
|
return args[0] switch
|
||||||
{
|
{
|
||||||
case StatusSwitch: return await ShowStatusAsync();
|
StatusSwitch => await ShowStatusAsync(),
|
||||||
case SetKeySwitch: return await SetKeyAsync(args.Length > 1 ? args[1] : null);
|
SetKeySwitch => await SetKeyAsync(args.Length > 1 ? args[1] : null),
|
||||||
case DeactivateSwitch: return await DeactivateAsync();
|
DeactivateSwitch => await DeactivateAsync(),
|
||||||
default: return 1;
|
_ => HeadlessRunner.ExitFailed
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<int> ShowStatusAsync()
|
private static async Task<int> ShowStatusAsync()
|
||||||
{
|
{
|
||||||
var hw = LicenseGuard.GetHardwareInfo();
|
var hw = LicenseGuard.GetHardwareInfo();
|
||||||
Console.WriteLine("Predictalytics — Lizenzstatus");
|
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($" Hardware-ID : {hw.HardwareId}");
|
||||||
Console.WriteLine($" HWID-Quelle : {hw.HwidSource} (v{hw.HwidVersion}, {hw.Platform})");
|
Console.WriteLine($" HWID-Quelle : {hw.HwidSource} (v{hw.HwidVersion}, {hw.Platform})");
|
||||||
Console.WriteLine($" Cache-Ablage : {LicenseGuard.StorageDirectory}");
|
Console.WriteLine($" Cache-Ablage : {LicenseGuard.StorageDirectory}");
|
||||||
|
|
||||||
var cachedKey = LicenseGuard.GetCachedLicenseKey();
|
var cachedKey = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
|
||||||
if (cachedKey == null)
|
if (string.IsNullOrWhiteSpace(cachedKey))
|
||||||
{
|
{
|
||||||
Console.WriteLine(" Schlüssel : keiner hinterlegt");
|
Console.WriteLine(" Schlüssel : keiner hinterlegt");
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
@@ -45,16 +50,28 @@ internal static class LicenseCli
|
|||||||
return HeadlessRunner.ExitNoLicense;
|
return HeadlessRunner.ExitNoLicense;
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($" Schlüssel : {Mask(cachedKey)}");
|
Console.WriteLine($" Schlüssel : {Mask(cachedKey!)}");
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.Write("Prüfe am Server… ");
|
Console.Write("Prüfe am Deployment Center… ");
|
||||||
|
|
||||||
var result = await LicenseGuard.ValidateCachedAsync();
|
var check = await LicenseGuard.TryUseCachedAsync();
|
||||||
Console.WriteLine(result is { IsValid: true }
|
var result = check.LastResult;
|
||||||
? $"gültig ({result.Status}{(result.IsCached ? ", aus lokalem Cache" : "")})"
|
|
||||||
|
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}");
|
: $"NICHT gültig ({result?.Status}): {result?.Message}");
|
||||||
|
return HeadlessRunner.ExitNoLicense;
|
||||||
return result is { IsValid: true } ? HeadlessRunner.ExitOk : HeadlessRunner.ExitNoLicense;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<int> SetKeyAsync(string? key)
|
private static async Task<int> SetKeyAsync(string? key)
|
||||||
@@ -65,10 +82,10 @@ internal static class LicenseCli
|
|||||||
return HeadlessRunner.ExitFailed;
|
return HeadlessRunner.ExitFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.Write($"Aktiviere {Mask(LicenseGuard.Normalize(key))} … ");
|
Console.Write($"Aktiviere {Mask(key.Trim().ToUpperInvariant())} … ");
|
||||||
var result = await LicenseGuard.ValidateAsync(key);
|
var (session, result) = await LicenseGuard.ActivateAsync(LicenseGuard.CreateClient(), key);
|
||||||
|
|
||||||
if (result.IsValid)
|
if (session is not null)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"erfolgreich ({result.Status})");
|
Console.WriteLine($"erfolgreich ({result.Status})");
|
||||||
Console.WriteLine($"Hardware-ID: {result.HardwareId}");
|
Console.WriteLine($"Hardware-ID: {result.HardwareId}");
|
||||||
@@ -81,14 +98,16 @@ internal static class LicenseCli
|
|||||||
|
|
||||||
private static async Task<int> DeactivateAsync()
|
private static async Task<int> DeactivateAsync()
|
||||||
{
|
{
|
||||||
if (LicenseGuard.GetCachedLicenseKey() == null)
|
var key = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Kein hinterlegter Schlüssel — nichts zu deaktivieren.");
|
Console.Error.WriteLine("Kein hinterlegter Schlüssel — nichts zu deaktivieren.");
|
||||||
return HeadlessRunner.ExitFailed;
|
return HeadlessRunner.ExitFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.Write("Gebe Aktivierungsplatz am Server frei… ");
|
Console.Write("Gebe Aktivierungsplatz am Deployment Center frei… ");
|
||||||
var ok = await LicenseGuard.DeactivateAsync();
|
var ok = await LicenseGuard.CreateClient()
|
||||||
|
.DeactivateAsync(DcConfig.ProductSlug, key!, DcConfig.BaseUrl);
|
||||||
Console.WriteLine(ok ? "erfolgreich" : "fehlgeschlagen");
|
Console.WriteLine(ok ? "erfolgreich" : "fehlgeschlagen");
|
||||||
return ok ? HeadlessRunner.ExitOk : HeadlessRunner.ExitFailed;
|
return ok ? HeadlessRunner.ExitOk : HeadlessRunner.ExitFailed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
|
|
||||||
private readonly PredictalyticsHost _host;
|
private readonly PredictalyticsHost _host;
|
||||||
private CancellationTokenSource? _workerCts;
|
private CancellationTokenSource? _workerCts;
|
||||||
private WatchdogHeartbeatService? _watchdog;
|
private DcHeartbeatService? _heartbeat;
|
||||||
|
private double? _lastDbSizeMb;
|
||||||
|
|
||||||
/// <summary>Wird gesetzt, sobald das Fenster steht — fuer Dialoge und Fehlermeldungen.</summary>
|
/// <summary>Wird gesetzt, sobald das Fenster steht — fuer Dialoge und Fehlermeldungen.</summary>
|
||||||
public Func<string, string, Task>? ShowInfo { get; set; }
|
public Func<string, string, Task>? ShowInfo { get; set; }
|
||||||
@@ -32,25 +33,32 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
|
|
||||||
[ObservableProperty] private string _statusText = "";
|
[ObservableProperty] private string _statusText = "";
|
||||||
[ObservableProperty] private string _dbSizeText = "DB Size: —";
|
[ObservableProperty] private string _dbSizeText = "DB Size: —";
|
||||||
[ObservableProperty] private string _buildVersionText = "Build: —";
|
[ObservableProperty] private string _buildVersionText = "";
|
||||||
[ObservableProperty] private string _serverButtonText = "▶ Start Server";
|
[ObservableProperty] private string _serverButtonText = "▶ Start Server";
|
||||||
[ObservableProperty] private string _webserverButtonText = "▶ Start Webserver";
|
[ObservableProperty] private string _webserverButtonText = "▶ Start Webserver";
|
||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
|
||||||
public MainWindowViewModel()
|
/// <summary>
|
||||||
|
/// Die Lizenz, auf der dieser Lauf beruht — wird nach der Lizenzschranke gesetzt und
|
||||||
|
/// vom Menuepunkt „Lizenzstatus" gelesen. Das ViewModel entsteht bewusst vorher, damit
|
||||||
|
/// das Terminal schon waehrend der Aktivierung mitschreibt.
|
||||||
|
/// </summary>
|
||||||
|
public LicenseSession? License { get; set; }
|
||||||
|
|
||||||
|
public MainWindowViewModel(PredictalyticsOptions options)
|
||||||
{
|
{
|
||||||
Options = PredictalyticsOptions.Load();
|
Options = options;
|
||||||
_host = new PredictalyticsHost(Options);
|
_host = new PredictalyticsHost(Options);
|
||||||
_host.StateChanged += () => Dispatcher.UIThread.Post(UpdateStatus);
|
_host.StateChanged += () => Dispatcher.UIThread.Post(UpdateStatus);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var buildDate = new FileInfo(GetType().Assembly.Location).LastWriteTime;
|
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
|
catch
|
||||||
{
|
{
|
||||||
BuildVersionText = "Build: Unknown";
|
BuildVersionText = $"v{DcConfig.AppVersion}";
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
@@ -69,7 +77,12 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
dbSizeTimer.Tick += async (_, _) => await RefreshDbSizeAsync();
|
dbSizeTimer.Tick += async (_, _) => await RefreshDbSizeAsync();
|
||||||
dbSizeTimer.Start();
|
dbSizeTimer.Start();
|
||||||
|
|
||||||
RestartWatchdog();
|
RestartHeartbeat();
|
||||||
|
|
||||||
|
if (Options.DcUpdateCheckEnabled)
|
||||||
|
{
|
||||||
|
_ = CheckForUpdatesAsync(silent: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Terminal-Sink: wird von Serilog aus beliebigen Threads gerufen.</summary>
|
/// <summary>Terminal-Sink: wird von Serilog aus beliebigen Threads gerufen.</summary>
|
||||||
@@ -92,33 +105,167 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
WebserverButtonText = _host.WebServerRunning ? "⏹ Stop Webserver" : "▶ Start Webserver";
|
WebserverButtonText = _host.WebServerRunning ? "⏹ Stop Webserver" : "▶ Start Webserver";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Watchdog ───
|
// ─── Deployment Center: Heartbeat ───
|
||||||
|
|
||||||
private void RestartWatchdog()
|
private void RestartHeartbeat()
|
||||||
{
|
{
|
||||||
_watchdog?.Dispose();
|
_heartbeat?.Dispose();
|
||||||
_watchdog = null;
|
_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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_watchdog = new WatchdogHeartbeatService(
|
_heartbeat = new DcHeartbeatService(
|
||||||
Options.WatchdogUrl,
|
Options.DcToken,
|
||||||
Options.WatchdogApiKey,
|
Options.DcSource,
|
||||||
Options.WatchdogSource,
|
Options.DcInstance,
|
||||||
Options.WatchdogInstance,
|
Options.DcHeartbeatIntervalSeconds,
|
||||||
Options.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)
|
||||||
|
{
|
||||||
|
var snapshot = new DcHeartbeatSnapshot
|
||||||
|
{
|
||||||
|
Message = _host.WorkersRunning ? "Worker laufen" : "Worker gestoppt"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deliberately no check for "workers stopped": that is a legitimate state chosen by
|
||||||
|
// the operator and would otherwise keep the monitor permanently on warning.
|
||||||
|
snapshot.Metrics["workers_running"] = _host.WorkersRunning ? 1 : 0;
|
||||||
|
snapshot.Metrics["webserver_running"] = _host.WebServerRunning ? 1 : 0;
|
||||||
|
snapshot.Metrics["memory_mb"] = Math.Round(GC.GetTotalMemory(forceFullCollection: false) / 1024d / 1024d, 1);
|
||||||
|
if (_lastDbSizeMb is { } dbSize) snapshot.Metrics["db_size_mb"] = Math.Round(dbSize, 2);
|
||||||
|
|
||||||
|
var db = await ProbeDatabaseAsync(ct);
|
||||||
|
if (db is not null) snapshot.Checks["db"] = db;
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SELECT 1 against the configured MySQL, capped so it cannot stall the heartbeat.</summary>
|
||||||
|
private async Task<DcCheck?> ProbeDatabaseAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Options.DbName)) return null;
|
||||||
|
|
||||||
|
var started = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeout.CancelAfter(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
await using var conn = new MySqlConnector.MySqlConnection(Options.ConnectionString);
|
||||||
|
await conn.OpenAsync(timeout.Token);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT 1";
|
||||||
|
await cmd.ExecuteScalarAsync(timeout.Token);
|
||||||
|
|
||||||
|
return new DcCheck(true, $"{started.ElapsedMilliseconds} ms", started.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new DcCheck(false, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deployment Center: Updates und Lizenz ───
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks the UpdateService for a newer release. Silent at startup (log + status bar);
|
||||||
|
/// only a critical release interrupts the user.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private Task CheckForUpdatesInteractiveAsync() => CheckForUpdatesAsync(silent: false);
|
||||||
|
|
||||||
|
private async Task CheckForUpdatesAsync(bool silent)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await DcUpdateService.CheckAsync(Options.DcUpdateChannel);
|
||||||
|
|
||||||
|
if (result.Error is not null)
|
||||||
{
|
{
|
||||||
workersRunning = _host.WorkersRunning,
|
Log.Warning("Update-Prüfung fehlgeschlagen: {Message}", result.Message);
|
||||||
webserverRunning = _host.WebServerRunning
|
if (!silent && ShowError != null)
|
||||||
});
|
await ShowError("Deployment Center", $"Update-Prüfung fehlgeschlagen:\n{result.Message}");
|
||||||
_watchdog.Start();
|
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 ───
|
// ─── Befehle ───
|
||||||
@@ -177,7 +324,8 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
private void SaveSettings()
|
private void SaveSettings()
|
||||||
{
|
{
|
||||||
Options.Save();
|
Options.Save();
|
||||||
RestartWatchdog();
|
DcErrorReporter.Configure(Options.DcToken, Options.DcErrorReportingEnabled);
|
||||||
|
RestartHeartbeat();
|
||||||
Log.Information("Einstellungen gespeichert: {Path}", PredictalyticsOptions.SettingsFilePath);
|
Log.Information("Einstellungen gespeichert: {Path}", PredictalyticsOptions.SettingsFilePath);
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
}
|
}
|
||||||
@@ -281,6 +429,7 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
private async Task RefreshDbSizeAsync()
|
private async Task RefreshDbSizeAsync()
|
||||||
{
|
{
|
||||||
var sizeMb = await _host.GetDatabaseSizeMbAsync();
|
var sizeMb = await _host.GetDatabaseSizeMbAsync();
|
||||||
|
_lastDbSizeMb = sizeMb;
|
||||||
DbSizeText = sizeMb.HasValue ? $"DB Size: {sizeMb.Value:F2} MB" : "DB Size: —";
|
DbSizeText = sizeMb.HasValue ? $"DB Size: {sizeMb.Value:F2} MB" : "DB Size: —";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,12 +449,12 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Beim Beenden: Watchdog abmelden, Worker und Webserver stoppen.</summary>
|
/// <summary>Beim Beenden: Monitor abmelden, Worker und Webserver stoppen.</summary>
|
||||||
public void Shutdown()
|
public void Shutdown()
|
||||||
{
|
{
|
||||||
_watchdog?.NotifyStopping();
|
_heartbeat?.NotifyStopping();
|
||||||
_watchdog?.Dispose();
|
_heartbeat?.Dispose();
|
||||||
_watchdog = null;
|
_heartbeat = null;
|
||||||
_workerCts?.Cancel();
|
_workerCts?.Cancel();
|
||||||
try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ }
|
try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,4 +16,7 @@ public static class OptionSources
|
|||||||
MySqlSslMode.Preferred,
|
MySqlSslMode.Preferred,
|
||||||
MySqlSslMode.Required
|
MySqlSslMode.Required
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>Release-Kanaele des UpdateService.</summary>
|
||||||
|
public static string[] UpdateChannels { get; } = ["prod", "beta", "dev"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<TextBox Name="KeyBox"
|
<TextBox Name="KeyBox"
|
||||||
FontFamily="Cascadia Code,DejaVu Sans Mono,Consolas,monospace"
|
FontFamily="Cascadia Code,DejaVu Sans Mono,Consolas,monospace"
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
Watermark="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" />
|
PlaceholderText="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" />
|
||||||
|
|
||||||
<TextBlock Name="StatusText" TextWrapping="Wrap" MinHeight="34" IsVisible="False" />
|
<TextBlock Name="StatusText" TextWrapping="Wrap" MinHeight="34" IsVisible="False" />
|
||||||
|
|
||||||
|
|||||||
@@ -8,20 +8,23 @@ namespace Predictalytics.Shell.Views;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Aktivierungsfenster, wenn keine nutzbare Lizenz vorliegt.
|
/// Aktivierungsfenster, wenn keine nutzbare Lizenz vorliegt.
|
||||||
/// <see cref="Completion"/> liefert true, sobald eine gueltige Lizenz vorliegt;
|
/// <see cref="Completion"/> liefert die Sitzung, sobald das Deployment Center die
|
||||||
/// false, wenn der Benutzer abbricht.
|
/// Aktivierung fuer diese Maschine bestaetigt hat; null, wenn der Benutzer abbricht.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class LicenseWindow : Window
|
public partial class LicenseWindow : Window
|
||||||
{
|
{
|
||||||
private readonly TaskCompletionSource<bool> _completion = new();
|
private readonly LicenseClient _client = null!;
|
||||||
|
private readonly TaskCompletionSource<LicenseSession?> _completion = new();
|
||||||
|
|
||||||
/// <summary>Wird abgeschlossen, wenn aktiviert oder abgebrochen wurde.</summary>
|
/// <summary>Wird abgeschlossen, wenn aktiviert oder abgebrochen wurde.</summary>
|
||||||
public Task<bool> Completion => _completion.Task;
|
public Task<LicenseSession?> Completion => _completion.Task;
|
||||||
|
|
||||||
public LicenseWindow()
|
public LicenseWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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
|
try
|
||||||
{
|
{
|
||||||
var hw = LicenseGuard.GetHardwareInfo();
|
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))
|
_client = client;
|
||||||
{
|
|
||||||
SetStatus($"Letzte Prüfung: {lastResult.Status} — {lastResult.Message}", isError: true);
|
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)
|
private async void OnActivateClick(object? sender, RoutedEventArgs e)
|
||||||
@@ -52,14 +62,14 @@ public partial class LicenseWindow : Window
|
|||||||
|
|
||||||
ActivateButton.IsEnabled = false;
|
ActivateButton.IsEnabled = false;
|
||||||
Busy.IsVisible = true;
|
Busy.IsVisible = true;
|
||||||
SetStatus("Prüfe Lizenz am Server…", isError: false);
|
SetStatus("Prüfe Lizenz am Deployment Center…", isError: false);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await LicenseGuard.ValidateAsync(key);
|
var (session, result) = await LicenseGuard.ActivateAsync(_client, key);
|
||||||
if (result.IsValid)
|
if (session is not null)
|
||||||
{
|
{
|
||||||
_completion.TrySetResult(true);
|
_completion.TrySetResult(session);
|
||||||
Close();
|
Close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -79,14 +89,14 @@ public partial class LicenseWindow : Window
|
|||||||
|
|
||||||
private void OnExitClick(object? sender, RoutedEventArgs e)
|
private void OnExitClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_completion.TrySetResult(false);
|
_completion.TrySetResult(null);
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnClosed(EventArgs e)
|
protected override void OnClosed(EventArgs e)
|
||||||
{
|
{
|
||||||
// Schliessen ueber das Fensterkreuz zaehlt als Abbruch.
|
// Schliessen ueber das Fensterkreuz zaehlt als Abbruch.
|
||||||
_completion.TrySetResult(false);
|
_completion.TrySetResult(null);
|
||||||
base.OnClosed(e);
|
base.OnClosed(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,10 @@
|
|||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem Header="Alle Trader neu berechnen…" Command="{Binding RecalculateAllCommand}" />
|
<MenuItem Header="Alle Trader neu berechnen…" Command="{Binding RecalculateAllCommand}" />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem Header="Deployment _Center">
|
||||||
|
<MenuItem Header="Nach Updates suchen" Command="{Binding CheckForUpdatesInteractiveCommand}" />
|
||||||
|
<MenuItem Header="Lizenzstatus anzeigen" Command="{Binding ShowLicenseStatusCommand}" />
|
||||||
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<!-- ─── Werkzeugleiste ─── -->
|
<!-- ─── Werkzeugleiste ─── -->
|
||||||
@@ -153,34 +157,62 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</HeaderedContentControl>
|
</HeaderedContentControl>
|
||||||
|
|
||||||
<!-- Watchdog -->
|
<!-- Deployment Center -->
|
||||||
<HeaderedContentControl Classes="group" Header="Watchdog">
|
<HeaderedContentControl Classes="group" Header="Deployment Center">
|
||||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||||
<Grid ColumnDefinitions="200,*">
|
<Grid ColumnDefinitions="200,*">
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Aktiv" />
|
<TextBlock Grid.Column="0" Classes="label" Text="Server-URL" />
|
||||||
<CheckBox Grid.Column="1" IsChecked="{Binding WatchdogEnabled}"
|
<StackPanel Grid.Column="1">
|
||||||
|
<SelectableTextBlock Text="{Binding DcServerUrl}" VerticalAlignment="Center" Opacity="0.8" />
|
||||||
|
<TextBlock Classes="hint"
|
||||||
|
Text="Fest einkompiliert — ein einstellbarer Endpoint würde erlauben, die App auf einen gefälschten Lizenz- oder Update-Server zu zeigen." />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="200,*">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" Text="API Token" />
|
||||||
|
<StackPanel Grid.Column="1">
|
||||||
|
<TextBox Text="{Binding DcToken}" PasswordChar="•" />
|
||||||
|
<TextBlock Classes="hint"
|
||||||
|
Text="Benötigte Rechte: 'watchdog:ping' für Heartbeats, 'bugtracker:report' für das Fehler-Reporting." />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="200,*">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" Text="Heartbeat aktiv" />
|
||||||
|
<CheckBox Grid.Column="1" IsChecked="{Binding DcHeartbeatEnabled}"
|
||||||
Content="Heartbeats senden (Dead-Man's-Switch)" />
|
Content="Heartbeats senden (Dead-Man's-Switch)" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid ColumnDefinitions="200,*">
|
<Grid ColumnDefinitions="200,*">
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Server-URL" />
|
<TextBlock Grid.Column="0" Classes="label" Text="Monitor Source" />
|
||||||
<TextBox Grid.Column="1" Text="{Binding WatchdogUrl}" />
|
<TextBox Grid.Column="1" Text="{Binding DcSource}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid ColumnDefinitions="200,*">
|
<Grid ColumnDefinitions="200,*">
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="API Key" />
|
<TextBlock Grid.Column="0" Classes="label" Text="Monitor Instance" />
|
||||||
<TextBox Grid.Column="1" Text="{Binding WatchdogApiKey}" PasswordChar="•" />
|
<TextBox Grid.Column="1" Text="{Binding DcInstance}" />
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Source" />
|
|
||||||
<TextBox Grid.Column="1" Text="{Binding WatchdogSource}" />
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Instanz" />
|
|
||||||
<TextBox Grid.Column="1" Text="{Binding WatchdogInstance}" />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid ColumnDefinitions="200,*">
|
<Grid ColumnDefinitions="200,*">
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Intervall (Sek.)" />
|
<TextBlock Grid.Column="0" Classes="label" Text="Intervall (Sek.)" />
|
||||||
<NumericUpDown Grid.Column="1" Value="{Binding WatchdogIntervalSeconds}"
|
<StackPanel Grid.Column="1">
|
||||||
Minimum="15" Maximum="3600" FormatString="0" HorizontalAlignment="Left" Width="160" />
|
<NumericUpDown Value="{Binding DcHeartbeatIntervalSeconds}"
|
||||||
|
Minimum="15" Maximum="3600" FormatString="0" HorizontalAlignment="Left" Width="160" />
|
||||||
|
<TextBlock Classes="hint"
|
||||||
|
Text="Der Evaluator stuft nach dem Doppelten auf 'warning' und nach dem Vierfachen auf 'down'." />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="200,*">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" Text="Fehler melden" />
|
||||||
|
<CheckBox Grid.Column="1" IsChecked="{Binding DcErrorReportingEnabled}"
|
||||||
|
Content="Laufzeitfehler (Error/Fatal) an den Fehler-Stream melden" />
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="200,*">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" Text="Update-Prüfung" />
|
||||||
|
<CheckBox Grid.Column="1" IsChecked="{Binding DcUpdateCheckEnabled}"
|
||||||
|
Content="Beim Start auf neuere Releases prüfen (installiert nichts automatisch)" />
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="200,*">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" Text="Update-Kanal" />
|
||||||
|
<ComboBox Grid.Column="1" SelectedItem="{Binding DcUpdateChannel}" Width="200"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
ItemsSource="{x:Static vm:OptionSources.UpdateChannels}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</HeaderedContentControl>
|
</HeaderedContentControl>
|
||||||
|
|||||||
-274
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
using Predictalytics.Hosting;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace Predictalytics.WinFormsHost;
|
|
||||||
|
|
||||||
public partial class MainForm : Form
|
|
||||||
{
|
|
||||||
private PredictalyticsHost _host = null!;
|
|
||||||
private CancellationTokenSource? _workerCts;
|
|
||||||
private PredictalyticsOptions _settings = null!;
|
|
||||||
private WatchdogHeartbeatService? _watchdog;
|
|
||||||
|
|
||||||
/// <summary>Exposes the terminal RichTextBox for the Serilog sink.</summary>
|
|
||||||
public RichTextBox Terminal => rtb_terminal;
|
|
||||||
|
|
||||||
public MainForm()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
this.Text = "Predictalytics Analytics — Backend Server";
|
|
||||||
rtb_terminal.BackColor = System.Drawing.Color.FromArgb(15, 15, 20);
|
|
||||||
rtb_terminal.ForeColor = System.Drawing.Color.FromArgb(180, 180, 180);
|
|
||||||
rtb_terminal.Font = new Font("Cascadia Code", 9.5f, FontStyle.Regular);
|
|
||||||
rtb_terminal.ReadOnly = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called after Serilog is configured. Initializes the application host.
|
|
||||||
/// </summary>
|
|
||||||
public void Initialize()
|
|
||||||
{
|
|
||||||
_settings = PredictalyticsOptions.Load();
|
|
||||||
// Der Host haelt dieselbe Options-Instanz — Aenderungen im PropertyGrid
|
|
||||||
// wirken damit ohne weitere Weitergabe beim naechsten Start.
|
|
||||||
_host = new PredictalyticsHost(_settings);
|
|
||||||
_host.StateChanged += () => BeginInvoke(UpdateStatusBar);
|
|
||||||
|
|
||||||
pg_settings.SelectedObject = _settings;
|
|
||||||
pg_settings.PropertyValueChanged += (s, e) =>
|
|
||||||
{
|
|
||||||
_settings.Save();
|
|
||||||
RestartWatchdog();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build Version (Date of compilation/file creation)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var buildDate = new FileInfo(this.GetType().Assembly.Location).LastWriteTime;
|
|
||||||
label_buildVersion.Text = $"Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
label_buildVersion.Text = "Build: Unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateStatusBar();
|
|
||||||
|
|
||||||
// Wire up button events
|
|
||||||
btn_serverstart.Click += Btn_serverstart_Click;
|
|
||||||
btn_localWebserver.Click += Btn_localWebserver_Click;
|
|
||||||
|
|
||||||
Log.Information("MainForm initialized. Ready.");
|
|
||||||
Log.Information("Press 'Start Server' to begin polling & discovery.");
|
|
||||||
Log.Information("Press 'Start Local Webserver' to launch the WebUI on {Url}", _settings.WebserverUrl);
|
|
||||||
Log.Information("Settings: {Path}", PredictalyticsOptions.SettingsFilePath);
|
|
||||||
|
|
||||||
_ = UpdateDbSizeAsync();
|
|
||||||
var dbSizeTimer = new System.Windows.Forms.Timer { Interval = 6 * 60 * 60 * 1000 };
|
|
||||||
dbSizeTimer.Tick += async (s, e) => await UpdateDbSizeAsync();
|
|
||||||
dbSizeTimer.Start();
|
|
||||||
|
|
||||||
RestartWatchdog();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// (Re-)creates the Watchdog heartbeat sender from the current settings.
|
|
||||||
/// Called at startup and whenever settings change.
|
|
||||||
/// </summary>
|
|
||||||
private void RestartWatchdog()
|
|
||||||
{
|
|
||||||
_watchdog?.Dispose();
|
|
||||||
_watchdog = null;
|
|
||||||
|
|
||||||
if (!_settings.WatchdogEnabled) return;
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_settings.WatchdogApiKey) || string.IsNullOrWhiteSpace(_settings.WatchdogUrl))
|
|
||||||
{
|
|
||||||
Log.Information("🐕 Watchdog ist aktiviert, aber URL/API Key fehlen — bitte in den Settings eintragen.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_watchdog = new WatchdogHeartbeatService(
|
|
||||||
_settings.WatchdogUrl,
|
|
||||||
_settings.WatchdogApiKey,
|
|
||||||
_settings.WatchdogSource,
|
|
||||||
_settings.WatchdogInstance,
|
|
||||||
_settings.WatchdogIntervalSeconds,
|
|
||||||
metadataProvider: () => new
|
|
||||||
{
|
|
||||||
workersRunning = _host.WorkersRunning,
|
|
||||||
webserverRunning = _host.WebServerRunning
|
|
||||||
});
|
|
||||||
_watchdog.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void Btn_serverstart_Click(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (!_host.WorkersRunning)
|
|
||||||
{
|
|
||||||
// Start workers
|
|
||||||
_workerCts = new CancellationTokenSource();
|
|
||||||
btn_serverstart.Text = "⏹ Stop Server";
|
|
||||||
Log.Information("🚀 Starting background workers...");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _host.StartWorkersAsync(_workerCts.Token);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) { }
|
|
||||||
catch (Exception ex) { Log.Error(ex, "Worker error"); }
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Stop workers
|
|
||||||
Log.Information("⏹ Stopping background workers...");
|
|
||||||
_workerCts?.Cancel();
|
|
||||||
btn_serverstart.Text = "▶ Start Server";
|
|
||||||
Log.Information("Workers stopped.");
|
|
||||||
}
|
|
||||||
UpdateStatusBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void Btn_localWebserver_Click(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (!_host.WebServerRunning)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Log.Information("🌐 Starting embedded Kestrel webserver on {Url}...", _settings.WebserverUrl);
|
|
||||||
await _host.StartWebServerAsync();
|
|
||||||
btn_localWebserver.Text = "⏹ Stop Webserver";
|
|
||||||
Log.Information("✅ WebUI available at {Url}", _settings.WebserverUrl);
|
|
||||||
Log.Information("📄 Swagger API docs at {Url}/swagger", _settings.WebserverUrl);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Failed to start webserver");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Log.Information("⏹ Stopping webserver...");
|
|
||||||
await _host.StopWebServerAsync();
|
|
||||||
btn_localWebserver.Text = "▶ Start Webserver";
|
|
||||||
Log.Information("Webserver stopped.");
|
|
||||||
}
|
|
||||||
UpdateStatusBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateStatusBar()
|
|
||||||
{
|
|
||||||
var workerStatus = _host.WorkersRunning ? "[RUNNING] Workers" : "[STOPPED] Workers";
|
|
||||||
var serverStatus = _host.WebServerRunning
|
|
||||||
? $"[RUNNING] Webserver :{_settings.WebserverPort}"
|
|
||||||
: "[STOPPED] Webserver";
|
|
||||||
this.Text = $"Predictalytics Analytics — {workerStatus} | {serverStatus}";
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
|
||||||
{
|
|
||||||
_watchdog?.NotifyStopping();
|
|
||||||
_watchdog?.Dispose();
|
|
||||||
_watchdog = null;
|
|
||||||
_workerCts?.Cancel();
|
|
||||||
_host?.StopWebServerAsync().GetAwaiter().GetResult();
|
|
||||||
base.OnFormClosing(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void btn_openbrowser_Click(object sender, EventArgs e)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
OpenInShell(_settings.WebserverUrl);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Fehler beim Öffnen des Browsers");
|
|
||||||
MessageBox.Show("Browser konnte nicht gestartet werden.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void btn_logfolder_Click(object sender, EventArgs e)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var logPath = LoggingSetup.DefaultLogDirectory;
|
|
||||||
OpenInShell(Directory.Exists(logPath) ? logPath : Environment.CurrentDirectory);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Fehler beim Öffnen des Log-Ordners");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Oeffnet Pfad oder URL mit der Standardanwendung. UseShellExecute funktioniert
|
|
||||||
/// unter Windows wie unter Linux (dort ueber xdg-open) — im Gegensatz zum
|
|
||||||
/// vorherigen direkten Aufruf von explorer.exe.
|
|
||||||
/// </summary>
|
|
||||||
private static void OpenInShell(string target) =>
|
|
||||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(target) { UseShellExecute = true });
|
|
||||||
|
|
||||||
private async void syncMarketsaToolStripMenuItem_Click(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (_host.WorkersRunning)
|
|
||||||
{
|
|
||||||
MessageBox.Show("Market sync cannot be started while background workers are running.",
|
|
||||||
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
btn_syncmarkets.Enabled = false;
|
|
||||||
Log.Information("Manual market sync triggered...");
|
|
||||||
|
|
||||||
// Use a temporary CTS for this operation
|
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
await _host.RunSingleMarketSyncAsync(cts.Token);
|
|
||||||
|
|
||||||
Log.Information("Manual market sync completed successfully.");
|
|
||||||
MessageBox.Show("Market sync completed.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Manual market sync failed");
|
|
||||||
MessageBox.Show($"Error syncing markets: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
btn_syncmarkets.Enabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void btn_dbUpdate_Click(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (_host.WorkersRunning)
|
|
||||||
{
|
|
||||||
MessageBox.Show("Database update cannot be run while background workers are running.",
|
|
||||||
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
btn_dbUpdate.Enabled = false;
|
|
||||||
Log.Information("Manual database update triggered...");
|
|
||||||
await _host.UpdateDatabaseAsync();
|
|
||||||
Log.Information("Database updated successfully.");
|
|
||||||
MessageBox.Show("Database update completed successfully.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Manual database update failed");
|
|
||||||
MessageBox.Show($"Database update failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
btn_dbUpdate.Enabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void btn_recalcAll_Click(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (_host.WorkersRunning)
|
|
||||||
{
|
|
||||||
MessageBox.Show("Recalculation cannot be started while background workers are running. Stop the server first.",
|
|
||||||
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var confirm = MessageBox.Show(
|
|
||||||
"This deletes all DERIVED analytics data (positions, daily snapshots, category stats) " +
|
|
||||||
"and marks every trader for full recalculation.\n\n" +
|
|
||||||
"Raw trades and markets are NOT touched.\n\n" +
|
|
||||||
"After this, start the server: the analytics worker rebuilds every trader with the current " +
|
|
||||||
"engine (runs in the background, can take several hours for large trader counts).\n\nContinue?",
|
|
||||||
"Recalculate All Traders", MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
|
|
||||||
if (confirm != DialogResult.Yes) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
btn_recalcAll.Enabled = false;
|
|
||||||
Log.Information("Manual full recalculation reset triggered...");
|
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
var summary = await _host.RunRecalculateAllTradersAsync(cts.Token);
|
|
||||||
|
|
||||||
MessageBox.Show($"Reset complete:\n\n{summary}\n\nNow start the server to rebuild the analytics.",
|
|
||||||
"Recalculate All Traders", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Full recalculation reset failed");
|
|
||||||
MessageBox.Show($"Recalculation reset failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
btn_recalcAll.Enabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateDbSizeAsync()
|
|
||||||
{
|
|
||||||
var sizeMb = await _host.GetDatabaseSizeMbAsync();
|
|
||||||
if (IsDisposed) return;
|
|
||||||
this.Invoke(() => label_dbSize.Text = sizeMb.HasValue
|
|
||||||
? $"DB Size: {sizeMb.Value:F2} MB"
|
|
||||||
: "DB Size: —");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<root>
|
|
||||||
<!--
|
|
||||||
Microsoft ResX Schema
|
|
||||||
|
|
||||||
Version 2.0
|
|
||||||
|
|
||||||
The primary goals of this format is to allow a simple XML format
|
|
||||||
that is mostly human readable. The generation and parsing of the
|
|
||||||
various data types are done through the TypeConverter classes
|
|
||||||
associated with the data types.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
... ado.net/XML headers & schema ...
|
|
||||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
|
||||||
<resheader name="version">2.0</resheader>
|
|
||||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
|
||||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
|
||||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
|
||||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
|
||||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
|
||||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
|
||||||
</data>
|
|
||||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
|
||||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
|
||||||
<comment>This is a comment</comment>
|
|
||||||
</data>
|
|
||||||
|
|
||||||
There are any number of "resheader" rows that contain simple
|
|
||||||
name/value pairs.
|
|
||||||
|
|
||||||
Each data row contains a name, and value. The row also contains a
|
|
||||||
type or mimetype. Type corresponds to a .NET class that support
|
|
||||||
text/value conversion through the TypeConverter architecture.
|
|
||||||
Classes that don't support this are serialized and stored with the
|
|
||||||
mimetype set.
|
|
||||||
|
|
||||||
The mimetype is used for serialized objects, and tells the
|
|
||||||
ResXResourceReader how to depersist the object. This is currently not
|
|
||||||
extensible. For a given mimetype the value must be set accordingly:
|
|
||||||
|
|
||||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
|
||||||
that the ResXResourceWriter will generate, however the reader can
|
|
||||||
read any of the formats listed below.
|
|
||||||
|
|
||||||
mimetype: application/x-microsoft.net.object.binary.base64
|
|
||||||
value : The object must be serialized with
|
|
||||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
|
||||||
: and then encoded with base64 encoding.
|
|
||||||
|
|
||||||
mimetype: application/x-microsoft.net.object.soap.base64
|
|
||||||
value : The object must be serialized with
|
|
||||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
|
||||||
: and then encoded with base64 encoding.
|
|
||||||
|
|
||||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
|
||||||
value : The object must be serialized into a byte array
|
|
||||||
: using a System.ComponentModel.TypeConverter
|
|
||||||
: and then encoded with base64 encoding.
|
|
||||||
-->
|
|
||||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
|
||||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
|
||||||
<xsd:element name="root" msdata:IsDataSet="true">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:choice maxOccurs="unbounded">
|
|
||||||
<xsd:element name="metadata">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:sequence>
|
|
||||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
|
||||||
</xsd:sequence>
|
|
||||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
|
||||||
<xsd:attribute name="type" type="xsd:string" />
|
|
||||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
|
||||||
<xsd:attribute ref="xml:space" />
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
<xsd:element name="assembly">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:attribute name="alias" type="xsd:string" />
|
|
||||||
<xsd:attribute name="name" type="xsd:string" />
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
<xsd:element name="data">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:sequence>
|
|
||||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
|
||||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
|
||||||
</xsd:sequence>
|
|
||||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
|
||||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
|
||||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
|
||||||
<xsd:attribute ref="xml:space" />
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
<xsd:element name="resheader">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:sequence>
|
|
||||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
|
||||||
</xsd:sequence>
|
|
||||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
</xsd:choice>
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
</xsd:schema>
|
|
||||||
<resheader name="resmimetype">
|
|
||||||
<value>text/microsoft-resx</value>
|
|
||||||
</resheader>
|
|
||||||
<resheader name="version">
|
|
||||||
<value>2.0</value>
|
|
||||||
</resheader>
|
|
||||||
<resheader name="reader">
|
|
||||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
|
||||||
</resheader>
|
|
||||||
<resheader name="writer">
|
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
|
||||||
</resheader>
|
|
||||||
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
|
||||||
<value>17, 17</value>
|
|
||||||
</metadata>
|
|
||||||
<metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
|
||||||
<value>162, 17</value>
|
|
||||||
</metadata>
|
|
||||||
<metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
|
||||||
<value>322, 17</value>
|
|
||||||
</metadata>
|
|
||||||
</root>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>WinExe</OutputType>
|
|
||||||
<!-- Ueberschreibt Directory.Build.props: WinForms braucht das Windows-Desktop-Pack.
|
|
||||||
Entfaellt, sobald die Avalonia-Shell den WinForms-Host abloest. -->
|
|
||||||
<TargetFramework>net10.0-windows</TargetFramework>
|
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
|
||||||
<RootNamespace>Predictalytics.WinFormsHost</RootNamespace>
|
|
||||||
<ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode>
|
|
||||||
<ApplicationVisualStyles>true</ApplicationVisualStyles>
|
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Direkt genutzt: Log.* in Program.cs und MainForm.cs -->
|
|
||||||
<PackageReference Include="Serilog" />
|
|
||||||
<!-- Fuer 'dotnet ef' mit diesem Projekt als Startprojekt -->
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Api, Worker, Infrastructure und Deploymentcenter.Client kommen transitiv ueber Hosting. -->
|
|
||||||
<ProjectReference Include="..\Predictalytics.Hosting\Predictalytics.Hosting.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Die WebUI wird neben die Programmdatei kopiert, damit PredictalyticsHost sie
|
|
||||||
ueber AppContext.BaseDirectory findet — ersetzt die fruehere Pfad-Heuristik. -->
|
|
||||||
<Content Include="..\Predictalytics.Api\wwwroot\**"
|
|
||||||
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
|
|
||||||
CopyToOutputDirectory="PreserveNewest"
|
|
||||||
CopyToPublishDirectory="PreserveNewest" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<Target Name="CleanupLocalization" AfterTargets="Build">
|
|
||||||
<ItemGroup>
|
|
||||||
<LanguageFolders Include="$(TargetDir)cs;$(TargetDir)de;$(TargetDir)es;$(TargetDir)fr;$(TargetDir)it;$(TargetDir)ja;$(TargetDir)ko;$(TargetDir)pl;$(TargetDir)pt-BR;$(TargetDir)ru;$(TargetDir)tr;$(TargetDir)zh-Hans;$(TargetDir)zh-Hant" />
|
|
||||||
</ItemGroup>
|
|
||||||
<RemoveDir Directories="@(LanguageFolders)" />
|
|
||||||
</Target>
|
|
||||||
</Project>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
using Predictalytics.Hosting;
|
|
||||||
using Predictalytics.WinFormsHost.Services;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace Predictalytics.WinFormsHost;
|
|
||||||
|
|
||||||
internal static class Program
|
|
||||||
{
|
|
||||||
[STAThread]
|
|
||||||
static void Main()
|
|
||||||
{
|
|
||||||
ApplicationConfiguration.Initialize();
|
|
||||||
|
|
||||||
// ─── License gate: no usable license, no app ───
|
|
||||||
if (!LicenseGate.EnsureLicensed())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var mainForm = new MainForm();
|
|
||||||
|
|
||||||
// Serilog-Aufbau liegt in Predictalytics.Hosting; hier wird lediglich der
|
|
||||||
// Terminal-Sink beigesteuert, der auf den UI-Thread marshallt.
|
|
||||||
LoggingSetup.Configure(TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm));
|
|
||||||
LoggingSetup.LogStartupBanner();
|
|
||||||
|
|
||||||
mainForm.Initialize();
|
|
||||||
|
|
||||||
// While running: re-check the license every 12 h (revocation/expiry/offline grace).
|
|
||||||
using var licenseWatch = LicenseGuard.StartPeriodicRevalidation(result =>
|
|
||||||
{
|
|
||||||
if (mainForm.IsDisposed) return;
|
|
||||||
mainForm.BeginInvoke(() =>
|
|
||||||
{
|
|
||||||
MessageBox.Show(
|
|
||||||
$"Die Lizenz ist nicht mehr gültig ({result.Status}):\n{result.Message}\n\nPredictalytics wird beendet.",
|
|
||||||
"Lizenzfehler", MessageBoxButtons.OK, MessageBoxIcon.Stop);
|
|
||||||
System.Windows.Forms.Application.Exit();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
System.Windows.Forms.Application.Run(mainForm);
|
|
||||||
|
|
||||||
Log.Information("Application shutting down.");
|
|
||||||
Log.CloseAndFlush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"profiles": {
|
|
||||||
"Predictalytics.WinFormsHost": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"launchBrowser": true,
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
},
|
|
||||||
"applicationUrl": "https://localhost:62271;http://localhost:62272"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
using Deploymentcenter.Client;
|
|
||||||
using Predictalytics.Hosting;
|
|
||||||
|
|
||||||
namespace Predictalytics.WinFormsHost.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Modal dialog shown at startup when no usable license is present.
|
|
||||||
/// Lets the user enter/activate a license key; closes with OK only after a
|
|
||||||
/// successful validation against the Deploymentcenter.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LicenseDialog : Form
|
|
||||||
{
|
|
||||||
private readonly Label _lblStatus;
|
|
||||||
private readonly TextBox _txtKey;
|
|
||||||
private readonly Button _btnActivate;
|
|
||||||
private readonly Button _btnExit;
|
|
||||||
|
|
||||||
public LicenseDialog(LicenseValidationResult? lastResult)
|
|
||||||
{
|
|
||||||
Text = "Predictalytics — Lizenzaktivierung";
|
|
||||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
|
||||||
MaximizeBox = false;
|
|
||||||
MinimizeBox = false;
|
|
||||||
StartPosition = FormStartPosition.CenterScreen;
|
|
||||||
ClientSize = new Size(480, 250);
|
|
||||||
|
|
||||||
var lblInfo = new Label
|
|
||||||
{
|
|
||||||
Text = "Diese Installation benötigt eine gültige Lizenz.\nBitte Lizenzschlüssel eingeben (Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX):",
|
|
||||||
Location = new Point(12, 12),
|
|
||||||
Size = new Size(456, 34)
|
|
||||||
};
|
|
||||||
|
|
||||||
_txtKey = new TextBox
|
|
||||||
{
|
|
||||||
Location = new Point(12, 52),
|
|
||||||
Size = new Size(456, 26),
|
|
||||||
Font = new Font("Consolas", 11f),
|
|
||||||
CharacterCasing = CharacterCasing.Upper
|
|
||||||
};
|
|
||||||
|
|
||||||
_lblStatus = new Label
|
|
||||||
{
|
|
||||||
Location = new Point(12, 84),
|
|
||||||
Size = new Size(456, 50),
|
|
||||||
ForeColor = Color.Firebrick,
|
|
||||||
Text = FormatInitialStatus(lastResult)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Die Aktivierung wird am Server an diese Hardware-ID gebunden.
|
|
||||||
var lblHardwareId = new Label
|
|
||||||
{
|
|
||||||
Location = new Point(12, 140),
|
|
||||||
Size = new Size(456, 60),
|
|
||||||
ForeColor = Color.Gray,
|
|
||||||
Font = new Font("Consolas", 7.5f),
|
|
||||||
Text = FormatHardwareId()
|
|
||||||
};
|
|
||||||
|
|
||||||
_btnActivate = new Button
|
|
||||||
{
|
|
||||||
Text = "Aktivieren",
|
|
||||||
Location = new Point(262, 206),
|
|
||||||
Size = new Size(100, 30)
|
|
||||||
};
|
|
||||||
_btnActivate.Click += async (_, _) => await ActivateAsync();
|
|
||||||
|
|
||||||
_btnExit = new Button
|
|
||||||
{
|
|
||||||
Text = "Beenden",
|
|
||||||
Location = new Point(368, 206),
|
|
||||||
Size = new Size(100, 30),
|
|
||||||
DialogResult = DialogResult.Cancel
|
|
||||||
};
|
|
||||||
|
|
||||||
AcceptButton = _btnActivate;
|
|
||||||
CancelButton = _btnExit;
|
|
||||||
Controls.AddRange([lblInfo, _txtKey, _lblStatus, lblHardwareId, _btnActivate, _btnExit]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatInitialStatus(LicenseValidationResult? lastResult)
|
|
||||||
{
|
|
||||||
if (lastResult == null || string.IsNullOrWhiteSpace(lastResult.Message)) return "";
|
|
||||||
return $"Letzte Prüfung: {lastResult.Status} — {lastResult.Message}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatHardwareId()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var hw = LicenseGuard.GetHardwareInfo();
|
|
||||||
return $"Hardware-ID: {hw.HardwareId}\nQuelle: {hw.HwidSource}";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return $"Hardware-ID nicht ermittelbar: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ActivateAsync()
|
|
||||||
{
|
|
||||||
var key = _txtKey.Text.Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(key))
|
|
||||||
{
|
|
||||||
_lblStatus.Text = "Bitte einen Lizenzschlüssel eingeben.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_btnActivate.Enabled = false;
|
|
||||||
_lblStatus.ForeColor = Color.DimGray;
|
|
||||||
_lblStatus.Text = "Prüfe Lizenz am Server...";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await LicenseGuard.ValidateAsync(key);
|
|
||||||
if (result.IsValid)
|
|
||||||
{
|
|
||||||
DialogResult = DialogResult.OK;
|
|
||||||
Close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_lblStatus.ForeColor = Color.Firebrick;
|
|
||||||
_lblStatus.Text = $"Lizenz nicht nutzbar ({result.Status}):\n{result.Message}";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_lblStatus.ForeColor = Color.Firebrick;
|
|
||||||
_lblStatus.Text = $"Fehler bei der Prüfung: {ex.Message}";
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_btnActivate.Enabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
using Predictalytics.Hosting;
|
|
||||||
|
|
||||||
namespace Predictalytics.WinFormsHost.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// WinForms-seitige Lizenzschranke: verbindet den plattformneutralen
|
|
||||||
/// <see cref="LicenseGuard"/> mit dem interaktiven Aktivierungsdialog.
|
|
||||||
/// </summary>
|
|
||||||
internal static class LicenseGate
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Blockiert, bis eine nutzbare Lizenz vorliegt. Zuerst wird der zwischengespeicherte
|
|
||||||
/// Schluessel geprueft; erst wenn der nicht (mehr) nutzbar ist, erscheint der Dialog.
|
|
||||||
/// Liefert false, wenn der Benutzer abbricht — die Anwendung muss dann beendet werden.
|
|
||||||
/// </summary>
|
|
||||||
public static bool EnsureLicensed()
|
|
||||||
{
|
|
||||||
var result = LicenseGuard.ValidateCachedAsync().GetAwaiter().GetResult();
|
|
||||||
if (result?.IsValid == true)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var dialog = new LicenseDialog(result);
|
|
||||||
return dialog.ShowDialog() == DialogResult.OK;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
using Serilog.Events;
|
|
||||||
|
|
||||||
namespace Predictalytics.WinFormsHost;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a thread-safe write action for the RichTextBox terminal.
|
|
||||||
/// </summary>
|
|
||||||
public static class TerminalHelper
|
|
||||||
{
|
|
||||||
public static Action<string, LogEventLevel> CreateWriteAction(RichTextBox rtb, Control owner)
|
|
||||||
{
|
|
||||||
int lineCount = 0;
|
|
||||||
const int maxLines = 500;
|
|
||||||
|
|
||||||
return (message, level) =>
|
|
||||||
{
|
|
||||||
if (owner.IsDisposed || rtb.IsDisposed) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
owner.BeginInvoke(() =>
|
|
||||||
{
|
|
||||||
if (rtb.IsDisposed) return;
|
|
||||||
|
|
||||||
lineCount++;
|
|
||||||
if (lineCount > maxLines)
|
|
||||||
{
|
|
||||||
rtb.Clear();
|
|
||||||
lineCount = 0;
|
|
||||||
rtb.AppendText("[Terminal cleared — log continues]\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
var color = level switch
|
|
||||||
{
|
|
||||||
LogEventLevel.Error or LogEventLevel.Fatal => System.Drawing.Color.FromArgb(255, 82, 82),
|
|
||||||
LogEventLevel.Warning => System.Drawing.Color.FromArgb(255, 193, 7),
|
|
||||||
LogEventLevel.Debug => System.Drawing.Color.FromArgb(158, 158, 158),
|
|
||||||
_ => System.Drawing.Color.FromArgb(76, 175, 80)
|
|
||||||
};
|
|
||||||
|
|
||||||
rtb.SelectionStart = rtb.TextLength;
|
|
||||||
rtb.SelectionLength = 0;
|
|
||||||
rtb.SelectionColor = color;
|
|
||||||
rtb.AppendText(message);
|
|
||||||
rtb.SelectionColor = rtb.ForeColor;
|
|
||||||
rtb.ScrollToCaret();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch { /* UI thread shutting down */ }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
|
||||||
<assemblyIdentity version="1.0.0.0" name="Predictalytics.app"/>
|
|
||||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
|
||||||
<security>
|
|
||||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
|
||||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
|
||||||
</requestedPrivileges>
|
|
||||||
</security>
|
|
||||||
</trustInfo>
|
|
||||||
<compatibility xmlns="urn:schemas-microsoft-com:asm.v1">
|
|
||||||
<application>
|
|
||||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
|
||||||
</application>
|
|
||||||
</compatibility>
|
|
||||||
</assembly>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"DefaultConnection": "Server=localhost;Database=Predictalytics_dev;User=root;Password="
|
|
||||||
},
|
|
||||||
"WebServer": {
|
|
||||||
"Port": 5000
|
|
||||||
},
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Warning",
|
|
||||||
"Microsoft.AspNetCore": "Warning",
|
|
||||||
"Microsoft.EntityFrameworkCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"OpenRouter": {
|
|
||||||
"ApiKey": "",
|
|
||||||
"BaseUrl": "https://openrouter.ai/api/v1",
|
|
||||||
"DefaultModel": "google/gemini-flash-1.5",
|
|
||||||
"ManualAnalysisModel": "anthropic/claude-3-opus"
|
|
||||||
},
|
|
||||||
"PlatformSettings": {
|
|
||||||
"Polymarket": {
|
|
||||||
"EnableCrawling": true
|
|
||||||
},
|
|
||||||
"Limitless": {
|
|
||||||
"EnableCrawling": false
|
|
||||||
},
|
|
||||||
"Azuro": {
|
|
||||||
"EnableCrawling": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Egress": {
|
|
||||||
"Channels": []
|
|
||||||
},
|
|
||||||
"ApiSettings": {
|
|
||||||
"CanControl": true,
|
|
||||||
"AuthRequired": false,
|
|
||||||
"AllowedOrigins": [ "http://localhost:5000" ],
|
|
||||||
"ReadOnlyDatabase": false
|
|
||||||
},
|
|
||||||
"RetentionSettings": {
|
|
||||||
"Enabled": true,
|
|
||||||
"RetentionDays": 180,
|
|
||||||
"CompactionDays": 14,
|
|
||||||
"MaxDatabaseSizeGb": 90.0,
|
|
||||||
"MinRetentionDays": 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user