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:
@@ -19,7 +19,8 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
|
||||
private readonly PredictalyticsHost _host;
|
||||
private CancellationTokenSource? _workerCts;
|
||||
private WatchdogHeartbeatService? _watchdog;
|
||||
private DcHeartbeatService? _heartbeat;
|
||||
private double? _lastDbSizeMb;
|
||||
|
||||
/// <summary>Wird gesetzt, sobald das Fenster steht — fuer Dialoge und Fehlermeldungen.</summary>
|
||||
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 _dbSizeText = "DB Size: —";
|
||||
[ObservableProperty] private string _buildVersionText = "Build: —";
|
||||
[ObservableProperty] private string _buildVersionText = "";
|
||||
[ObservableProperty] private string _serverButtonText = "▶ Start Server";
|
||||
[ObservableProperty] private string _webserverButtonText = "▶ Start Webserver";
|
||||
[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.StateChanged += () => Dispatcher.UIThread.Post(UpdateStatus);
|
||||
|
||||
try
|
||||
{
|
||||
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
|
||||
{
|
||||
BuildVersionText = "Build: Unknown";
|
||||
BuildVersionText = $"v{DcConfig.AppVersion}";
|
||||
}
|
||||
|
||||
UpdateStatus();
|
||||
@@ -69,7 +77,12 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
dbSizeTimer.Tick += async (_, _) => await RefreshDbSizeAsync();
|
||||
dbSizeTimer.Start();
|
||||
|
||||
RestartWatchdog();
|
||||
RestartHeartbeat();
|
||||
|
||||
if (Options.DcUpdateCheckEnabled)
|
||||
{
|
||||
_ = CheckForUpdatesAsync(silent: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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";
|
||||
}
|
||||
|
||||
// ─── Watchdog ───
|
||||
// ─── Deployment Center: Heartbeat ───
|
||||
|
||||
private void RestartWatchdog()
|
||||
private void RestartHeartbeat()
|
||||
{
|
||||
_watchdog?.Dispose();
|
||||
_watchdog = null;
|
||||
_heartbeat?.Dispose();
|
||||
_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;
|
||||
}
|
||||
|
||||
_watchdog = new WatchdogHeartbeatService(
|
||||
Options.WatchdogUrl,
|
||||
Options.WatchdogApiKey,
|
||||
Options.WatchdogSource,
|
||||
Options.WatchdogInstance,
|
||||
Options.WatchdogIntervalSeconds,
|
||||
metadataProvider: () => new
|
||||
_heartbeat = new DcHeartbeatService(
|
||||
Options.DcToken,
|
||||
Options.DcSource,
|
||||
Options.DcInstance,
|
||||
Options.DcHeartbeatIntervalSeconds,
|
||||
CollectHeartbeatSnapshotAsync);
|
||||
_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,
|
||||
webserverRunning = _host.WebServerRunning
|
||||
});
|
||||
_watchdog.Start();
|
||||
Log.Warning("Update-Prüfung fehlgeschlagen: {Message}", result.Message);
|
||||
if (!silent && ShowError != null)
|
||||
await ShowError("Deployment Center", $"Update-Prüfung fehlgeschlagen:\n{result.Message}");
|
||||
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 ───
|
||||
@@ -177,7 +324,8 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
private void SaveSettings()
|
||||
{
|
||||
Options.Save();
|
||||
RestartWatchdog();
|
||||
DcErrorReporter.Configure(Options.DcToken, Options.DcErrorReportingEnabled);
|
||||
RestartHeartbeat();
|
||||
Log.Information("Einstellungen gespeichert: {Path}", PredictalyticsOptions.SettingsFilePath);
|
||||
UpdateStatus();
|
||||
}
|
||||
@@ -281,6 +429,7 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
private async Task RefreshDbSizeAsync()
|
||||
{
|
||||
var sizeMb = await _host.GetDatabaseSizeMbAsync();
|
||||
_lastDbSizeMb = sizeMb;
|
||||
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()
|
||||
{
|
||||
_watchdog?.NotifyStopping();
|
||||
_watchdog?.Dispose();
|
||||
_watchdog = null;
|
||||
_heartbeat?.NotifyStopping();
|
||||
_heartbeat?.Dispose();
|
||||
_heartbeat = null;
|
||||
_workerCts?.Cancel();
|
||||
try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ }
|
||||
}
|
||||
|
||||
@@ -16,4 +16,7 @@ public static class OptionSources
|
||||
MySqlSslMode.Preferred,
|
||||
MySqlSslMode.Required
|
||||
];
|
||||
|
||||
/// <summary>Release-Kanaele des UpdateService.</summary>
|
||||
public static string[] UpdateChannels { get; } = ["prod", "beta", "dev"];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user