@
Integrate analytics feature stack with Watchdog/License host integration Merges the full copytrading-analytics stack (UI fixes, insider feed, fingerprint snapshot foundation, strategy-drift alarm, edge-freshness, smart-money co-movement detection + graph clustering, copy-portfolio) with the WinFormsHost Watchdog heartbeat + LicenseLabrador license gate. No file overlap between the two lines. @
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# Betrieb: Watchdog-Überwachung & LicenseLabrador-Lizenzierung
|
||||
|
||||
Beide Integrationen sitzen im `Predictalytics.WinFormsHost` (dem Produktiv-Host) und
|
||||
binden zwei eigenständige Schwester-Projekte an:
|
||||
|
||||
| Projekt | Pfad | Rolle |
|
||||
|---|---|---|
|
||||
| Watchdog | `J:\Softwareprojekte\WatchDog` | PHP/MySQL-Server auf `watchdog.mhdf.de`, empfängt Heartbeats |
|
||||
| LicenseLabrador | `J:\Softwareprojekte\LicenseLabrador` | PHP-Lizenzserver auf `license.mhdf.de` + C#-SDK |
|
||||
|
||||
---
|
||||
|
||||
## 1. Watchdog (Dead-Man's-Switch)
|
||||
|
||||
`Services/WatchdogHeartbeatService.cs` sendet alle *n* Sekunden einen
|
||||
`POST /api/heartbeat` an den Watchdog. Bleiben die Heartbeats aus — weil die App
|
||||
abgestürzt ist oder die ganze Maschine weg ist — schlägt der Watchdog Alarm.
|
||||
Beim regulären Schließen geht ein `POST /api/event` mit `kind=stopped_graceful`
|
||||
raus, damit ein geplantes Beenden nicht als Crash alarmiert wird.
|
||||
|
||||
> **Nicht auf `kind=stopping` ändern.** Der Watchdog akzeptiert den Wert im
|
||||
> Router, aber `event_log.kind` ist ein ENUM ohne `stopping`. Der Server setzt
|
||||
> dann zwar noch den Zustand auf `stopped`, scheitert aber am Insert und liefert
|
||||
> HTTP 500 — das Event fehlt in der Historie. Verifiziert am 2026-07-30.
|
||||
|
||||
**Wichtig:** Ein Ausfall des Watchdogs darf Predictalytics nie beeinträchtigen.
|
||||
Alle Aufrufe sind best effort; der erste Fehlschlag wird als Warnung geloggt,
|
||||
Folgefehler nur noch auf Debug-Level (keine Log-Flut).
|
||||
|
||||
### Konfiguration (PropertyGrid im Host, Kategorie „Watchdog")
|
||||
|
||||
| Feld | Default | Bedeutung |
|
||||
|---|---|---|
|
||||
| `Enabled` | `true` | Heartbeats an/aus |
|
||||
| `Server URL` | `https://watchdog.mhdf.de` | Basis-URL |
|
||||
| `API Key` | *(leer)* | `X-Watchdog-Key` — Shared Key **oder** Agent-Token. Ohne Key passiert nichts. |
|
||||
| `Source` | `Predictalytics` | Monitor-Name im Dashboard (Auto-Registrierung beim ersten Beat) |
|
||||
| `Instance` | `default` | falls mehrere Instanzen laufen |
|
||||
| `Interval (Sekunden)` | `60` | Sende-Takt; Alarm nach ca. `Intervall × 1,5 + 30 s` |
|
||||
|
||||
Der Key landet in der `settings.json` neben der Exe (nicht im Git).
|
||||
|
||||
### PolyTrader-Maschine mit überwachen
|
||||
|
||||
Der Heartbeat aus Predictalytics deckt nur *diesen* Prozess ab. Damit auch die
|
||||
Maschine überwacht wird, auf der PolyTrader läuft, gehört dort zusätzlich der
|
||||
OS-Agent hin: `WatchDog\agents\windows\watchdog-agent.ps1` als Scheduled Task
|
||||
(inkl. Shutdown-Hook), bzw. `agents/linux/watchdog-agent.sh` per systemd-Timer.
|
||||
|
||||
---
|
||||
|
||||
## 2. LicenseLabrador (Kopierschutz)
|
||||
|
||||
`Services/LicenseGuard.cs` prüft beim Start, ob eine nutzbare Lizenz vorliegt
|
||||
(`Program.Main` bricht sonst ab, bevor die MainForm überhaupt entsteht).
|
||||
Ohne gültige Lizenz erscheint `Services/LicenseDialog.cs` zur Key-Eingabe.
|
||||
|
||||
Produkt-Slug, Endpoint und der Ed25519-Public-Key sind **bewusst einkompiliert**
|
||||
und nicht konfigurierbar — ein einstellbarer Endpoint würde erlauben, die App auf
|
||||
einen gefälschten Lizenzserver zu zeigen.
|
||||
|
||||
- **Produkt-Slug:** `predictalytics`
|
||||
- **Offline-Gnadenfrist:** 168 h (7 Tage) — danach ist Serverkontakt nötig
|
||||
- **Revalidierung zur Laufzeit:** alle 12 h; bei Widerruf/Ablauf beendet sich die App
|
||||
- **Härtung:** `VerifyChecksum` (HMAC über State + Key + Hardware-ID) gegen Memory-Patches;
|
||||
Nonce-Reflexion und Signaturprüfung übernimmt das SDK
|
||||
|
||||
### Status
|
||||
|
||||
Produkt `predictalytics` und Lizenzschlüssel sind angelegt; die Aktivierung wurde
|
||||
am 2026-07-30 gegen den Produktivserver verifiziert (`Valid`, gültig bis
|
||||
2040-12-31, Checksum-Prüfung bestanden). Der einkompilierte Public Key passt zum
|
||||
`signing.pub` des Servers — sonst käme `TamperSuspected` statt `Valid`.
|
||||
|
||||
Die Aktivierung ist an die Hardware-ID der jeweiligen Maschine gebunden. Auf
|
||||
einem neuen Rechner erscheint einmalig der Dialog; der Cache liegt danach unter
|
||||
`%AppData%\predictalytics\license`.
|
||||
|
||||
### Deaktivierung bei PC-Wechsel
|
||||
|
||||
`LicenseClient.DeactivateAsync()` gibt die Aktivierung wieder frei. Aktuell nicht
|
||||
in der UI verdrahtet — bei Bedarf als Menüpunkt ergänzen.
|
||||
@@ -27,6 +27,42 @@ public class AppSettings
|
||||
[Editor("System.ComponentModel.Design.MultilineStringEditor, System.Design, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof(System.Drawing.Design.UITypeEditor))]
|
||||
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
|
||||
|
||||
[Category("Watchdog")]
|
||||
[DisplayName("Enabled")]
|
||||
[Description("Sendet periodische Heartbeats an den externen Watchdog-Server (Dead-Man's-Switch). Benötigt einen API Key.")]
|
||||
[DefaultValue(true)]
|
||||
public bool WatchdogEnabled { get; set; } = true;
|
||||
|
||||
[Category("Watchdog")]
|
||||
[DisplayName("Server URL")]
|
||||
[Description("Basis-URL des Watchdog-Servers.")]
|
||||
[DefaultValue("https://watchdog.mhdf.de")]
|
||||
public string WatchdogUrl { get; set; } = "https://watchdog.mhdf.de";
|
||||
|
||||
[Category("Watchdog")]
|
||||
[DisplayName("API Key")]
|
||||
[Description("Shared Key oder Agent-Token des Watchdog-Servers (X-Watchdog-Key). Ohne Key werden keine Heartbeats gesendet.")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string WatchdogApiKey { get; set; } = "";
|
||||
|
||||
[Category("Watchdog")]
|
||||
[DisplayName("Source")]
|
||||
[Description("Eindeutiger Monitor-Name dieses Dienstes im Watchdog-Dashboard.")]
|
||||
[DefaultValue("Predictalytics")]
|
||||
public string WatchdogSource { get; set; } = "Predictalytics";
|
||||
|
||||
[Category("Watchdog")]
|
||||
[DisplayName("Instance")]
|
||||
[Description("Instanz-Kennung, falls mehrere Predictalytics-Instanzen laufen.")]
|
||||
[DefaultValue("default")]
|
||||
public string WatchdogInstance { get; set; } = "default";
|
||||
|
||||
[Category("Watchdog")]
|
||||
[DisplayName("Interval (Sekunden)")]
|
||||
[Description("Sende-Takt der Heartbeats. Der Watchdog alarmiert, wenn ~1,5× dieses Intervall + 30 s ohne Heartbeat vergehen.")]
|
||||
[DefaultValue(60)]
|
||||
public int WatchdogIntervalSeconds { get; set; } = 60;
|
||||
|
||||
private string _dbServer = "localhost";
|
||||
private string _dbName = "";
|
||||
private string _dbUser = "";
|
||||
|
||||
@@ -11,6 +11,7 @@ public partial class MainForm : Form
|
||||
private bool _workerRunning;
|
||||
private bool _webServerRunning;
|
||||
private AppSettings _settings = null!;
|
||||
private WatchdogHeartbeatService? _watchdog;
|
||||
|
||||
/// <summary>Exposes the terminal RichTextBox for the Serilog sink.</summary>
|
||||
public RichTextBox Terminal => rtb_terminal;
|
||||
@@ -40,6 +41,7 @@ public partial class MainForm : Form
|
||||
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
|
||||
_webServer.EgressChannelsText = _settings.EgressChannelsText;
|
||||
}
|
||||
RestartWatchdog();
|
||||
};
|
||||
|
||||
_webServer = new EmbeddedWebServer();
|
||||
@@ -69,6 +71,39 @@ public partial class MainForm : Form
|
||||
var dbSizeTimer = new System.Windows.Forms.Timer { Interval = 6 * 60 * 60 * 1000 };
|
||||
dbSizeTimer.Tick += async (s, e) => await UpdateDbSizeAsync();
|
||||
dbSizeTimer.Start();
|
||||
|
||||
RestartWatchdog();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (Re-)creates the Watchdog heartbeat sender from the current settings.
|
||||
/// Called at startup and whenever settings change.
|
||||
/// </summary>
|
||||
private void RestartWatchdog()
|
||||
{
|
||||
_watchdog?.Dispose();
|
||||
_watchdog = null;
|
||||
|
||||
if (!_settings.WatchdogEnabled) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_settings.WatchdogApiKey) || string.IsNullOrWhiteSpace(_settings.WatchdogUrl))
|
||||
{
|
||||
Log.Information("🐕 Watchdog ist aktiviert, aber URL/API Key fehlen — bitte in den Settings eintragen.");
|
||||
return;
|
||||
}
|
||||
|
||||
_watchdog = new WatchdogHeartbeatService(
|
||||
_settings.WatchdogUrl,
|
||||
_settings.WatchdogApiKey,
|
||||
_settings.WatchdogSource,
|
||||
_settings.WatchdogInstance,
|
||||
_settings.WatchdogIntervalSeconds,
|
||||
metadataProvider: () => new
|
||||
{
|
||||
workersRunning = _workerRunning,
|
||||
webserverRunning = _webServerRunning
|
||||
});
|
||||
_watchdog.Start();
|
||||
}
|
||||
|
||||
private async void Btn_serverstart_Click(object? sender, EventArgs e)
|
||||
@@ -142,6 +177,9 @@ public partial class MainForm : Form
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
_watchdog?.NotifyStopping();
|
||||
_watchdog?.Dispose();
|
||||
_watchdog = null;
|
||||
_workerCts?.Cancel();
|
||||
_webServer?.StopWebServerAsync().GetAwaiter().GetResult();
|
||||
base.OnFormClosing(e);
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
<ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" />
|
||||
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
|
||||
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
||||
<!-- Externes Schwester-Repo: J:\Softwareprojekte\LicenseLabrador muss neben dem Predictalytics-Checkout liegen. -->
|
||||
<ProjectReference Include="..\..\..\..\LicenseLabrador\client-dotnet\LicenseLabrador.Client\LicenseLabrador.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CleanupLocalization" AfterTargets="Build">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Predictalytics.Infrastructure.Logging;
|
||||
using Predictalytics.WinFormsHost.Services;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
|
||||
@@ -11,6 +12,13 @@ internal static class Program
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
// ─── License gate: no usable license, no app ───
|
||||
var licenseClient = LicenseGuard.EnsureLicensed();
|
||||
if (licenseClient == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var mainForm = new MainForm();
|
||||
var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm);
|
||||
|
||||
@@ -145,6 +153,10 @@ internal static class Program
|
||||
Log.Warning("══════════════════════════════════════════════════════");
|
||||
|
||||
mainForm.Initialize();
|
||||
|
||||
// While running: re-check the license every 12 h (revocation/expiry/offline grace).
|
||||
using var licenseTimer = LicenseGuard.StartPeriodicRevalidation(licenseClient);
|
||||
|
||||
System.Windows.Forms.Application.Run(mainForm);
|
||||
|
||||
Log.Information("Application shutting down.");
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using LicenseLabrador.Client;
|
||||
|
||||
namespace Predictalytics.WinFormsHost.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Modal dialog shown at startup when no usable license is present.
|
||||
/// Lets the user enter/activate a license key; closes with OK only after a
|
||||
/// successful, checksum-verified validation.
|
||||
/// </summary>
|
||||
public sealed class LicenseDialog : Form
|
||||
{
|
||||
private readonly LicenseClient _client;
|
||||
private readonly Label _lblStatus;
|
||||
private readonly TextBox _txtKey;
|
||||
private readonly Button _btnActivate;
|
||||
private readonly Button _btnExit;
|
||||
|
||||
public LicenseResult? Result { get; private set; }
|
||||
|
||||
public LicenseDialog(LicenseClient client, LicenseResult? lastResult)
|
||||
{
|
||||
_client = client;
|
||||
|
||||
Text = "Predictalytics — Lizenzaktivierung";
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
ClientSize = new Size(460, 190);
|
||||
|
||||
var lblInfo = new Label
|
||||
{
|
||||
Text = "Diese Installation benötigt eine gültige Lizenz.\nBitte Lizenzschlüssel eingeben (Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX):",
|
||||
Location = new Point(12, 12),
|
||||
Size = new Size(436, 34)
|
||||
};
|
||||
|
||||
_txtKey = new TextBox
|
||||
{
|
||||
Location = new Point(12, 52),
|
||||
Size = new Size(436, 26),
|
||||
Font = new Font("Consolas", 11f),
|
||||
CharacterCasing = CharacterCasing.Upper
|
||||
};
|
||||
|
||||
_lblStatus = new Label
|
||||
{
|
||||
Location = new Point(12, 84),
|
||||
Size = new Size(436, 50),
|
||||
ForeColor = Color.Firebrick,
|
||||
Text = FormatInitialStatus(lastResult)
|
||||
};
|
||||
|
||||
_btnActivate = new Button
|
||||
{
|
||||
Text = "Aktivieren",
|
||||
Location = new Point(252, 146),
|
||||
Size = new Size(96, 30)
|
||||
};
|
||||
_btnActivate.Click += async (_, _) => await ActivateAsync();
|
||||
|
||||
_btnExit = new Button
|
||||
{
|
||||
Text = "Beenden",
|
||||
Location = new Point(354, 146),
|
||||
Size = new Size(94, 30),
|
||||
DialogResult = DialogResult.Cancel
|
||||
};
|
||||
|
||||
AcceptButton = _btnActivate;
|
||||
CancelButton = _btnExit;
|
||||
Controls.AddRange(new Control[] { lblInfo, _txtKey, _lblStatus, _btnActivate, _btnExit });
|
||||
}
|
||||
|
||||
private static string FormatInitialStatus(LicenseResult? lastResult)
|
||||
{
|
||||
if (lastResult == null || lastResult.State == LicenseState.NoLicense) return "";
|
||||
return $"Letzte Prüfung: {lastResult.State} — {lastResult.Message}";
|
||||
}
|
||||
|
||||
private async Task ActivateAsync()
|
||||
{
|
||||
var key = _txtKey.Text.Trim();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
_lblStatus.Text = "Bitte einen Lizenzschlüssel eingeben.";
|
||||
return;
|
||||
}
|
||||
|
||||
_btnActivate.Enabled = false;
|
||||
_lblStatus.ForeColor = Color.DimGray;
|
||||
_lblStatus.Text = "Prüfe Lizenz am Server...";
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _client.ValidateAsync(key);
|
||||
if (result.IsUsable && _client.VerifyChecksum(result))
|
||||
{
|
||||
Result = result;
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
|
||||
_lblStatus.ForeColor = Color.Firebrick;
|
||||
_lblStatus.Text = $"Lizenz nicht nutzbar ({result.State}):\n{result.Message}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_lblStatus.ForeColor = Color.Firebrick;
|
||||
_lblStatus.Text = $"Fehler bei der Prüfung: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_btnActivate.Enabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using LicenseLabrador.Client;
|
||||
using Serilog;
|
||||
|
||||
namespace Predictalytics.WinFormsHost.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Startup license gate backed by the LicenseLabrador server.
|
||||
/// Endpoint, product slug and the Ed25519 public key are deliberately compiled in
|
||||
/// (not user configuration): a configurable endpoint/key would let anyone point the
|
||||
/// app at a fake license server.
|
||||
/// </summary>
|
||||
public static class LicenseGuard
|
||||
{
|
||||
private const string ProductSlug = "predictalytics";
|
||||
private const string PublicKeyBase64 = "L7YR1wMKk8+lNefatzL+DMvAtHFVkZWYXAxXGrro+/U=";
|
||||
private const string BasicAuthUser = "Labrador";
|
||||
private const string BasicAuthPassword = "Labrador02763!";
|
||||
private static readonly string[] Endpoints = { "http://license.mhdf.de/public/api/v1" };
|
||||
|
||||
/// <summary>Re-check interval while the app is running (12 h).</summary>
|
||||
public const int RevalidationIntervalMs = 12 * 60 * 60 * 1000;
|
||||
|
||||
public static LicenseClient CreateClient()
|
||||
{
|
||||
var config = new LicenseConfig
|
||||
{
|
||||
ProductSlug = ProductSlug,
|
||||
PublicKeyBase64 = PublicKeyBase64,
|
||||
Endpoints = Endpoints,
|
||||
HttpBasicAuthUser = BasicAuthUser,
|
||||
HttpBasicAuthPassword = BasicAuthPassword,
|
||||
OfflineGraceHoursFallback = 168 // 7 Tage offline nutzbar, danach Serverkontakt nötig
|
||||
};
|
||||
return new LicenseClient(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocks until a usable license is present. Tries the cached key first; otherwise
|
||||
/// (or when the cached key is no longer usable) shows the license dialog.
|
||||
/// Returns null if the user gave up — the app must exit then.
|
||||
/// </summary>
|
||||
public static LicenseClient? EnsureLicensed()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var result = client.RevalidateAsync().GetAwaiter().GetResult();
|
||||
if (result.IsUsable && client.VerifyChecksum(result))
|
||||
{
|
||||
return client;
|
||||
}
|
||||
|
||||
using var dialog = new LicenseDialog(client, result);
|
||||
if (dialog.ShowDialog() != DialogResult.OK)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the periodic in-app revalidation. Detects revocation/expiry while the app
|
||||
/// keeps running; on a definitively unusable license the app is shut down.
|
||||
/// </summary>
|
||||
public static System.Windows.Forms.Timer StartPeriodicRevalidation(LicenseClient client)
|
||||
{
|
||||
var timer = new System.Windows.Forms.Timer { Interval = RevalidationIntervalMs };
|
||||
timer.Tick += async (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await client.RevalidateAsync();
|
||||
if (result.IsUsable && client.VerifyChecksum(result))
|
||||
{
|
||||
if (result.State == LicenseState.ValidOffline)
|
||||
{
|
||||
Log.Warning("Lizenzserver nicht erreichbar — Offline-Gnadenfrist läuft bis {GraceUntil}.", result.GraceUntil);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
timer.Stop();
|
||||
Log.Fatal("Lizenzprüfung fehlgeschlagen ({State}): {Message} — Anwendung wird beendet.", result.State, result.Message);
|
||||
MessageBox.Show(
|
||||
$"Die Lizenz ist nicht mehr gültig ({result.State}):\n{result.Message}\n\nPredictalytics wird beendet.",
|
||||
"Lizenzfehler", MessageBoxButtons.OK, MessageBoxIcon.Stop);
|
||||
System.Windows.Forms.Application.Exit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transient errors (network etc.) are handled by the SDK's offline grace —
|
||||
// never kill the app from an unexpected exception here.
|
||||
Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht).");
|
||||
}
|
||||
};
|
||||
timer.Start();
|
||||
return timer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Serilog;
|
||||
|
||||
namespace Predictalytics.WinFormsHost.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Sends periodic dead-man's-switch heartbeats to the external Watchdog server
|
||||
/// (POST /api/heartbeat) so an outage of this app — or the whole machine — raises
|
||||
/// an alarm. A Watchdog outage must never impact the app: every call is best effort.
|
||||
/// </summary>
|
||||
public sealed class WatchdogHeartbeatService : IDisposable
|
||||
{
|
||||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
|
||||
private readonly string _baseUrl;
|
||||
private readonly string _apiKey;
|
||||
private readonly string _source;
|
||||
private readonly string _instance;
|
||||
private readonly int _intervalSeconds;
|
||||
private readonly Func<object?>? _metadataProvider;
|
||||
private readonly DateTime _startedUtc = DateTime.UtcNow;
|
||||
|
||||
private System.Threading.Timer? _timer;
|
||||
private bool _lastSendFailed;
|
||||
|
||||
public WatchdogHeartbeatService(
|
||||
string baseUrl,
|
||||
string apiKey,
|
||||
string source,
|
||||
string instance,
|
||||
int intervalSeconds,
|
||||
Func<object?>? metadataProvider = null)
|
||||
{
|
||||
_baseUrl = baseUrl.TrimEnd('/');
|
||||
_apiKey = apiKey;
|
||||
_source = source;
|
||||
_instance = string.IsNullOrWhiteSpace(instance) ? "default" : instance;
|
||||
_intervalSeconds = Math.Max(15, intervalSeconds);
|
||||
_metadataProvider = metadataProvider;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = new System.Threading.Timer(
|
||||
async _ => await SendHeartbeatAsync("ok"),
|
||||
null, TimeSpan.Zero, TimeSpan.FromSeconds(_intervalSeconds));
|
||||
Log.Information("🐕 Watchdog heartbeat started → {Url} (source={Source}, every {Interval}s)",
|
||||
_baseUrl, _source, _intervalSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports a planned shutdown so it is not alarmed as a crash.
|
||||
/// Must be "stopped_graceful": the server's event_log.kind ENUM has no "stopping"
|
||||
/// value, and that variant fails the insert with HTTP 500 after the state update.
|
||||
/// </summary>
|
||||
public void NotifyStopping()
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
source = _source,
|
||||
instance = _instance,
|
||||
kind = "stopped_graceful",
|
||||
severity = "info",
|
||||
message = "Predictalytics wird planmäßig beendet."
|
||||
};
|
||||
// Synchronous with a short cap: the form is closing and must not hang.
|
||||
PostAsync("/api/event", payload).Wait(TimeSpan.FromSeconds(4));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendHeartbeatAsync(string status)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
source = _source,
|
||||
instance = _instance,
|
||||
type = "heartbeat",
|
||||
status,
|
||||
message = (string?)null,
|
||||
metrics = new
|
||||
{
|
||||
uptimeSec = (long)(DateTime.UtcNow - _startedUtc).TotalSeconds,
|
||||
app = _metadataProvider?.Invoke()
|
||||
},
|
||||
group = "C# Applications",
|
||||
interval = _intervalSeconds
|
||||
};
|
||||
|
||||
await PostAsync("/api/heartbeat", payload);
|
||||
|
||||
if (_lastSendFailed)
|
||||
{
|
||||
_lastSendFailed = false;
|
||||
Log.Information("🐕 Watchdog heartbeat wieder erfolgreich zugestellt.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log the first failure as warning, subsequent ones quietly (no log flood).
|
||||
if (!_lastSendFailed)
|
||||
{
|
||||
_lastSendFailed = true;
|
||||
Log.Warning("🐕 Watchdog heartbeat fehlgeschlagen (weitere Fehler werden unterdrückt): {Error}", ex.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug(ex, "Watchdog heartbeat failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PostAsync(string path, object payload)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path);
|
||||
request.Headers.Add("X-Watchdog-Key", _apiKey);
|
||||
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await _http.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException($"Watchdog API HTTP {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
_http.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user