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:
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user