Phase 2: Plattformneutralen Hosting-Kern extrahiert
Neues Projekt Predictalytics.Hosting nimmt auf, was bisher im windows-gebundenen WinFormsHost feststeckte, aber portabel ist: - PredictalyticsHost (aus EmbeddedWebServer): Kestrel- und Worker-Lifecycle, Wartungsaktionen, DB-Groesse. Meldet Zustandswechsel ueber StateChanged. - PredictalyticsOptions (aus AppSettings): ohne WinForms-Bezug. Die System.ComponentModel-Attribute sind plattformneutral und bleiben, damit das PropertyGrid Gruppen und Beschreibungen behaelt. - LoggingSetup (aus Program.cs): Serilog-Aufbau, Terminal-Sink als optionale Action statt fester RichTextBox. - LicenseGuard: GUI-frei. Periodische Revalidierung ueber PeriodicTimer statt WinForms-Timer, Abbruch ueber Callback statt Application.Exit. Der interaktive Dialogaufruf bleibt als LicenseGate im WinForms-Host. - WatchdogHeartbeatService unveraendert verschoben. Infrastructure: RichTextBoxSink -> DelegateSink umbenannt (war nie WinForms-abhaengig, nur missverstaendlich benannt). Einstellungen liegen jetzt unter %APPDATA%/Predictalytics bzw. ~/.config/Predictalytics statt neben der Programmdatei, mit einmaliger Uebernahme aus dem alten Ort. Das Installationsverzeichnis ist unter Linux ueblicherweise nicht beschreibbar. wwwroot wird ueber einen Content-Eintrag neben die Programmdatei kopiert; die frueheren Pfad-Heuristiken entfallen. Hosting und WinFormsHost nutzen Microsoft.NET.Sdk statt Sdk.Web: der Web-SDK globbt wwwroot automatisch als Static Web Asset und kollidiert mit dem Content-Eintrag. WebApplication kommt ueber FrameworkReference. Neu konfigurierbar (verhaltensgleiche Defaults): WebserverHost fuer die Kestrel-Bind-Adresse, DbSslMode fuer die MySQL-Verschluesselung. explorer.exe-Aufrufe durch ProcessStartInfo mit UseShellExecute ersetzt — funktioniert unter Windows und Linux. Build: 0 Fehler. Tests: 100 bestanden, 0 Fehler, 1 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user