Phase 3: Avalonia-Bedienhuelle fuer Windows und Linux

Neues Projekt Predictalytics.Shell (Avalonia 12.1.1, net10.0) als
plattformuebergreifender Ersatz fuer den WinForms-Host. Setzt auf dem in
Phase 2 extrahierten Predictalytics.Hosting auf.

Oberflaeche:
- MainWindow mit Menue, Werkzeugleiste, Terminal-Tab, Einstellungen-Tab und
  Statusleiste. Die feste Fenstergroesse von 1886x1088 ist aufgeloest.
- Terminal als ListBox ueber ObservableCollection mit Einfaerbung nach
  Loglevel. Ringpuffer statt des bisherigen kompletten Leerens bei 500 Zeilen.
- Einstellungsansicht handgeschrieben als Ersatz fuer den PropertyGrid, den
  es in Avalonia nicht gibt: vier Gruppen, Passwortfelder, mehrzeiliges
  Textfeld fuer die Egress-Kanaele, ComboBox fuer den SSL-Modus. Gespeichert
  wird explizit statt bei jeder Einzeloperation.
- LicenseWindow ersetzt den WinForms-LicenseDialog.
- Dialogs.cs als schlanker Ersatz fuer MessageBox.Show, ohne Drittanbieter.

Headless-Modus (--headless) im selben Binary: fuer Linux-Server ohne
Desktop-Session. Die Argumentauswertung steht vor jeder Avalonia-
Initialisierung, sonst stirbt der Prozess ohne X11 bevor der Schalter greift.
Lizenz ueber PREDICTALYTICS_LICENSE_KEY statt Dialog, SIGTERM und SIGINT
ueber PosixSignalRegistration fuer sauberes systemd-Stop.
Exit-Codes: 0 planmaessig, 2 keine nutzbare Lizenz, 3 Fehler.

Plattformdetails: Schrift-Fallbackkette fuers Terminal, Avalonia.Fonts.Inter
als mitgelieferte UI-Schrift, UTF-8 fuer die Windows-Konsole im
Headless-Modus.

Die Avalonia-Basisklasse wird als global::Avalonia.Application angegeben:
innerhalb von Predictalytics.* loest der kurze Name auf den eigenen
Namespace Predictalytics.Application auf.

Build: 0 Fehler, 0 Warnungen. Tests: 100 bestanden.
Verifiziert: --headless laeuft bis zur Lizenzschranke und beendet sich mit
Exit-Code 2; GUI startet und zeigt das Lizenzfenster. Das Hauptfenster
konnte nicht geprueft werden, weil die hinterlegte Lizenz abgelaufen ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-07 10:15:56 +02:00
co-authored by Claude Opus 5
parent 260dff1700
commit 1efd23abe4
18 changed files with 1305 additions and 1 deletions
@@ -0,0 +1,39 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
namespace Predictalytics.Shell.Services;
/// <summary>
/// Konsolen-Vorbereitung fuer den Headless-Modus.
/// <para>
/// Das Projekt ist als WinExe gebaut, damit unter Windows kein Konsolenfenster
/// hinter der GUI steht. Im Headless-Modus fehlt dadurch aber die Ausgabe, wenn
/// aus einer Eingabeaufforderung gestartet wird — <see cref="TryAttachToParent"/>
/// haengt den Prozess an die Konsole des aufrufenden Prozesses an.
/// </para>
/// <para>Unter Linux ist WinExe gleichbedeutend mit Exe; dort entfaellt das Anhaengen.</para>
/// </summary>
internal static class ConsoleAttach
{
private const int AttachParentProcess = -1;
public static void TryAttachToParent()
{
if (OperatingSystem.IsWindows())
{
try { AttachConsole(AttachParentProcess); }
catch { /* ohne Konsole bleiben die Logdateien */ }
}
// Die Logausgabe enthaelt Rahmenzeichen und Emoji. Linux-Terminals sind
// ohnehin UTF-8, die Windows-Konsole laeuft ohne das hier auf der
// ANSI-Codepage und zeigt nur Fragezeichen.
try { Console.OutputEncoding = Encoding.UTF8; }
catch { /* keine Konsole angehaengt — irrelevant */ }
}
[SupportedOSPlatform("windows")]
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AttachConsole(int dwProcessId);
}
@@ -0,0 +1,165 @@
using System.Runtime.InteropServices;
using Predictalytics.Hosting;
using Serilog;
namespace Predictalytics.Shell.Services;
/// <summary>
/// Betrieb ohne Oberflaeche — fuer Linux-Server ohne Desktop-Session
/// (systemd, Container). Startet Webserver und Worker und laeuft, bis
/// SIGTERM oder Strg+C eintrifft.
/// </summary>
internal static class HeadlessRunner
{
/// <summary>Umgebungsvariable fuer die Aktivierung ohne Dialog.</summary>
private const string LicenseKeyVariable = "PREDICTALYTICS_LICENSE_KEY";
public const int ExitOk = 0;
public const int ExitNoLicense = 2;
public const int ExitFailed = 3;
public static async Task<int> RunAsync()
{
var options = PredictalyticsOptions.Load();
// Kein Terminal-Sink: Ausgabe geht auf die Konsole und in die Logdateien.
LoggingSetup.Configure();
LoggingSetup.LogStartupBanner();
Log.Information("Headless-Modus. Einstellungen: {Path}", PredictalyticsOptions.SettingsFilePath);
try
{
var client = await EnsureLicensedAsync();
if (client == null) return ExitNoLicense;
var host = new PredictalyticsHost(options);
using var cts = new CancellationTokenSource();
using var shutdownSignals = RegisterShutdown(cts);
using var licenseWatch = LicenseGuard.StartPeriodicRevalidation(client, result =>
{
Log.Fatal("Lizenz nicht mehr gültig ({State}): {Message} — Dienst wird beendet.",
result.State, result.Message);
cts.Cancel();
});
using var watchdog = StartWatchdog(options, host);
await host.StartWebServerAsync();
Log.Information("WebUI erreichbar unter {Url}", options.WebserverUrl);
// Laeuft, bis abgebrochen wird.
await host.StartWorkersAsync(cts.Token);
watchdog?.NotifyStopping();
await host.StopWebServerAsync();
Log.Information("Dienst planmäßig beendet.");
return ExitOk;
}
catch (OperationCanceledException)
{
Log.Information("Dienst planmäßig beendet.");
return ExitOk;
}
catch (Exception ex)
{
Log.Fatal(ex, "Headless-Betrieb abgebrochen.");
return ExitFailed;
}
finally
{
await Log.CloseAndFlushAsync();
}
}
/// <summary>
/// Lizenzpruefung ohne Dialog: zuerst der zwischengespeicherte Schluessel, sonst
/// einmalige Aktivierung mit dem Schluessel aus der Umgebungsvariable.
/// </summary>
private static async Task<LicenseLabrador.Client.LicenseClient?> EnsureLicensedAsync()
{
var client = LicenseGuard.CreateClient();
var result = await LicenseGuard.RevalidateAsync(client);
if (LicenseGuard.IsUsable(client, result)) return client;
var key = Environment.GetEnvironmentVariable(LicenseKeyVariable);
if (string.IsNullOrWhiteSpace(key))
{
Log.Fatal("Keine nutzbare Lizenz ({State}: {Message}) und {Variable} ist nicht gesetzt. " +
"Im Headless-Betrieb gibt es keinen Aktivierungsdialog — bitte den Lizenzschlüssel " +
"über die Umgebungsvariable bereitstellen.",
result.State, result.Message, LicenseKeyVariable);
return null;
}
Log.Information("Aktiviere Lizenz mit dem Schlüssel aus {Variable}…", LicenseKeyVariable);
result = await client.ValidateAsync(key.Trim().ToUpperInvariant());
if (LicenseGuard.IsUsable(client, result))
{
Log.Information("Lizenz aktiviert ({State}).", result.State);
return client;
}
Log.Fatal("Aktivierung fehlgeschlagen ({State}): {Message}", result.State, result.Message);
return null;
}
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;
}
var watchdog = new WatchdogHeartbeatService(
options.WatchdogUrl,
options.WatchdogApiKey,
options.WatchdogSource,
options.WatchdogInstance,
options.WatchdogIntervalSeconds,
metadataProvider: () => new
{
workersRunning = host.WorkersRunning,
webserverRunning = host.WebServerRunning
});
watchdog.Start();
return watchdog;
}
/// <summary>
/// SIGTERM (systemd stop), SIGINT und Strg+C sauber behandeln, damit der Dienst
/// nicht hart abgeschossen wird.
/// </summary>
private static IDisposable RegisterShutdown(CancellationTokenSource cts)
{
var registrations = new List<IDisposable>();
void Handle(PosixSignalContext context)
{
context.Cancel = true; // Standardverhalten (sofortiges Beenden) unterdruecken
Log.Information("Signal {Signal} empfangen — fahre herunter…", context.Signal);
cts.Cancel();
}
registrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGTERM, Handle));
registrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGINT, Handle));
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
return new CompositeDisposable(registrations);
}
private sealed class CompositeDisposable(List<IDisposable> items) : IDisposable
{
public void Dispose()
{
foreach (var item in items)
{
try { item.Dispose(); } catch { /* beendet sich ohnehin */ }
}
}
}
}