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:
Richard
2026-08-06 23:13:04 +02:00
co-authored by Claude Opus 5
parent c9eff9f75e
commit 260dff1700
14 changed files with 716 additions and 424 deletions
@@ -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();
}
}