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
+14
View File
@@ -57,6 +57,20 @@
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
<!-- Avalonia — Bedienhuelle fuer Windows und Linux.
12.1.1 liefert native net10.0-Assemblies.
Avalonia.Diagnostics (DevTools) gibt es noch nicht fuer 12.x; die
Debug-Referenz bleibt daher auf der 11er-Linie und ist optional. -->
<ItemGroup>
<PackageVersion Include="Avalonia" Version="12.1.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<!-- Mitgelieferte Schrift: Linux-Distributionen bringen sehr unterschiedliche
UI-Fonts mit, damit sieht die Oberflaeche ueberall gleich aus. -->
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
<!-- Test -->
<ItemGroup>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
+1
View File
@@ -6,6 +6,7 @@
<Project Path="src/Predictalytics.Domain/Predictalytics.Domain.csproj" />
<Project Path="src/Predictalytics.Hosting/Predictalytics.Hosting.csproj" />
<Project Path="src/Predictalytics.Infrastructure/Predictalytics.Infrastructure.csproj" />
<Project Path="src/Predictalytics.Shell/Predictalytics.Shell.csproj" />
<Project Path="src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj" />
<Project Path="src/Predictalytics.Worker/Predictalytics.Worker.csproj" />
</Folder>
+62 -1
View File
@@ -298,7 +298,68 @@ ausschließlich UI-Code. `Predictalytics.Hosting` baut ohne Windows-Referenzen.
---
## 5. Phase 3 — Avalonia-Bedienhülle (46 PT)
## 5. Phase 3 — Avalonia-Bedienhülle ✅ **erledigt (2026-08-07)**
**Abnahme:** `dotnet build` → 0 Fehler, 0 Warnungen. `dotnet test` → 100 bestanden.
Beide Betriebsarten laufen an:
| Prüfung | Ergebnis |
|---|---|
| `Predictalytics.Shell.exe --headless` | Logging läuft, Lizenzprüfung greift, sauberer Abbruch mit **Exit-Code 2** und handlungsfähiger Meldung |
| `Predictalytics.Shell.exe` (GUI) | startet, Lizenzfenster erscheint, kein Absturz |
| Konsolen-Kodierung | UTF-8 explizit gesetzt — ohne das zeigt die Windows-Konsole nur Fragezeichen statt Rahmen und Emoji |
### ⚠️ Nicht verifiziert: das Hauptfenster
Die hinterlegte Lizenz ist **abgelaufen** (`Expired: License has expired`). Beide
Betriebsarten bleiben deshalb an der Lizenzschranke stehen — `MainWindow`, Terminal,
Einstellungsansicht und die Wartungsaktionen sind compilergeprüft, aber **noch nie
angezeigt worden**. Nach Erneuerung der Lizenz nachzuholen.
### Umsetzung
| Datei | Zweck |
|---|---|
| `Program.cs` | Einstiegspunkt. Argumentauswertung **vor** jeder Avalonia-Initialisierung — sonst stirbt der Prozess auf einem Server ohne X11, bevor `--headless` greift. |
| `App.axaml(.cs)` | Lizenzschranke, dann Hauptfenster. `ShutdownMode.OnExplicitShutdown` während der Aktivierung, danach `OnMainWindowClose`. |
| `ViewModels/MainWindowViewModel.cs` | Bindungsziel, `CommunityToolkit.Mvvm`-Quellgeneratoren, kapselt `PredictalyticsHost` |
| `ViewModels/LogLine.cs` | Terminalzeile mit Einfärbung nach Loglevel (Farbschema aus WinForms übernommen) |
| `Views/MainWindow.axaml` | Menü, Werkzeugleiste, Terminal-Tab, Einstellungen-Tab, Statusleiste |
| `Views/LicenseWindow.axaml` | Aktivierungsfenster, ersetzt `LicenseDialog` |
| `Views/Dialogs.cs` | Ersatz für `MessageBox.Show` — im Code aufgebaut, kein Drittanbieterpaket |
| `Services/HeadlessRunner.cs` | AP 3.4: Betrieb ohne Oberfläche |
| `Services/ConsoleAttach.cs` | Windows-Konsole anhängen + UTF-8 |
### Abweichungen und Entscheidungen
* **`PropertyGrid`-Ersatz handgeschrieben**, wie empfohlen. Vier Gruppen als
`HeaderedContentControl`, Passwortfelder mit `PasswordChar`, **echtes mehrzeiliges
Textfeld** für die Egress-Kanäle (besser als das in Phase 2 entfallene `[Editor]`-Attribut).
SSL-Modus als `ComboBox`; bewusst nur `None`/`Preferred`/`Required``VerifyCA`/`VerifyFull`
bräuchten hinterlegte Zertifikate und würden hier nur zu Fehlkonfiguration einladen.
* **Speichern ist jetzt explizit** („Speichern & übernehmen") statt wie beim `PropertyGrid`
bei jeder Einzeländerung. Klarer, und erspart `INotifyPropertyChanged` auf `PredictalyticsOptions`.
* **Feste Fenstergröße aufgelöst** (vorher `1886×1088` fix): jetzt `MinWidth`/`MinHeight`
mit skalierendem `DockPanel`/`Grid`.
* **Schrift-Fallbackkette** `Cascadia Code, DejaVu Sans Mono, Liberation Mono, Consolas, monospace`
fürs Terminal; `Avalonia.Fonts.Inter` als mitgelieferte UI-Schrift, weil Linux-Distributionen
sehr unterschiedliche Standardfonts haben.
* **Ringpuffer statt Leeren**: die WinForms-Fassung verwarf bei 500 Zeilen das gesamte
Terminal (`rtb.Clear()`), jetzt fällt jeweils nur die älteste Zeile heraus.
* **`Avalonia.Diagnostics` nicht referenziert** — gibt es nur bis 11.3.18, nicht für 12.x.
* **`global::Avalonia.Application`**: innerhalb von `Predictalytics.*` löst der kurze Name
`Application` auf den eigenen Namespace `Predictalytics.Application` auf. Betrifft jede
künftige Avalonia-Klasse in diesem Projekt.
* **`Microsoft.NET.Sdk` statt `Sdk.Web`**, wie in Phase 2 gelernt (Static-Web-Assets-Kollision
mit dem `wwwroot`-Content-Eintrag).
### Headless-Betrieb
Lizenz über `PREDICTALYTICS_LICENSE_KEY`; ohne nutzbare Lizenz Abbruch mit Exit-Code 2
statt eines Dialogs, den niemand sieht. `SIGTERM` und `SIGINT` werden über
`PosixSignalRegistration` abgefangen, damit systemd sauber stoppen kann.
Exit-Codes: `0` planmäßig beendet · `2` keine nutzbare Lizenz · `3` Abbruch mit Fehler.
### AP 3.1 — Projekt `Predictalytics.Shell`
```xml
+10
View File
@@ -0,0 +1,10 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Predictalytics.Shell.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+92
View File
@@ -0,0 +1,92 @@
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Predictalytics.Hosting;
using Predictalytics.Shell.ViewModels;
using Predictalytics.Shell.Views;
using Serilog;
namespace Predictalytics.Shell;
/// <summary>
/// Avalonia-Anwendungsobjekt.
/// <para>
/// Die Basisklasse wird voll qualifiziert angegeben: innerhalb von
/// <c>Predictalytics.*</c> loest der kurze Name <c>Application</c> auf den
/// Namespace <c>Predictalytics.Application</c> auf, nicht auf den Avalonia-Typ.
/// </para>
/// </summary>
public partial class App : global::Avalonia.Application
{
private IDisposable? _licenseWatch;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Das Lizenzfenster darf beim Schliessen nicht die Anwendung beenden —
// danach soll ja erst das Hauptfenster kommen.
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
desktop.Exit += (_, _) => _licenseWatch?.Dispose();
// Nicht awaiten: OnFrameworkInitializationCompleted muss zurueckkehren,
// damit die Nachrichtenschleife anlaufen kann.
Dispatcher.UIThread.Post(async () => await StartupAsync(desktop));
}
base.OnFrameworkInitializationCompleted();
}
private async Task StartupAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
// ─── Lizenzschranke: ohne nutzbare Lizenz keine Anwendung ───
var client = LicenseGuard.CreateClient();
var result = await LicenseGuard.RevalidateAsync(client);
if (!LicenseGuard.IsUsable(client, result))
{
var licenseWindow = new LicenseWindow(client, result);
desktop.MainWindow = licenseWindow;
licenseWindow.Show();
if (!await licenseWindow.Completion)
{
desktop.Shutdown();
return;
}
}
// ─── Hauptfenster ───
var viewModel = new MainWindowViewModel();
var window = new MainWindow(viewModel);
// Serilog-Aufbau liegt in Predictalytics.Hosting; hier kommt nur der
// Terminal-Sink dazu, der selbst auf den UI-Thread marshallt.
LoggingSetup.Configure(viewModel.AppendLog);
LoggingSetup.LogStartupBanner();
desktop.MainWindow = window;
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
window.Show();
viewModel.Start();
// Waehrend des Betriebs alle 12 h nachpruefen (Widerruf, Ablauf, Offline-Frist).
_licenseWatch = LicenseGuard.StartPeriodicRevalidation(client, unusable =>
Dispatcher.UIThread.Post(async () =>
{
await Dialogs.ShowErrorAsync(window, "Lizenzfehler",
$"Die Lizenz ist nicht mehr gültig ({unusable.State}):\n{unusable.Message}\n\nPredictalytics wird beendet.");
desktop.Shutdown();
}));
desktop.Exit += (_, _) =>
{
Log.Information("Application shutting down.");
Log.CloseAndFlush();
};
}
}
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- WinExe: unter Windows kein Konsolenfenster hinter der GUI. Unter Linux
ist WinExe gleichbedeutend mit Exe. Fuer den Headless-Modus wird unter
Windows die Elternkonsole angehaengt (siehe ConsoleAttach). -->
<OutputType>WinExe</OutputType>
<RootNamespace>Predictalytics.Shell</RootNamespace>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Serilog" />
</ItemGroup>
<ItemGroup>
<!-- Api, Worker, Infrastructure und LicenseLabrador.Client kommen transitiv ueber Hosting. -->
<ProjectReference Include="..\Predictalytics.Hosting\Predictalytics.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Die WebUI wird neben die Programmdatei kopiert, damit PredictalyticsHost sie
ueber AppContext.BaseDirectory findet. -->
<Content Include="..\Predictalytics.Api\wwwroot\**"
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+29
View File
@@ -0,0 +1,29 @@
using Avalonia;
using Predictalytics.Shell.Services;
namespace Predictalytics.Shell;
internal static class Program
{
[STAThread]
public static int Main(string[] args)
{
// Die Argumentauswertung steht bewusst vor jeder Avalonia-Initialisierung:
// auf einem Server ohne X11 wuerde der Prozess sonst beim Aufbau des
// Fenstersystems sterben, bevor --headless ueberhaupt greift.
if (args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase)))
{
ConsoleAttach.TryAttachToParent();
return HeadlessRunner.RunAsync().GetAwaiter().GetResult();
}
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
/// <summary>Wird auch vom Avalonia-Designer verwendet.</summary>
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
@@ -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 */ }
}
}
}
}
@@ -0,0 +1,26 @@
using Avalonia.Media;
using Serilog.Events;
namespace Predictalytics.Shell.ViewModels;
/// <summary>Eine Zeile der Terminalanzeige samt Einfaerbung nach Loglevel.</summary>
public sealed class LogLine
{
public string Text { get; }
public IBrush Foreground { get; }
public LogLine(string text, LogEventLevel level)
{
Text = text;
Foreground = BrushFor(level);
}
// Farbschema unveraendert aus der WinForms-Fassung uebernommen.
private static IBrush BrushFor(LogEventLevel level) => level switch
{
LogEventLevel.Error or LogEventLevel.Fatal => new SolidColorBrush(Color.FromRgb(255, 82, 82)),
LogEventLevel.Warning => new SolidColorBrush(Color.FromRgb(255, 193, 7)),
LogEventLevel.Debug or LogEventLevel.Verbose => new SolidColorBrush(Color.FromRgb(158, 158, 158)),
_ => new SolidColorBrush(Color.FromRgb(76, 175, 80))
};
}
@@ -0,0 +1,312 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Predictalytics.Hosting;
using Serilog;
using Serilog.Events;
namespace Predictalytics.Shell.ViewModels;
/// <summary>
/// Bindungsziel des Hauptfensters. Haelt den <see cref="PredictalyticsHost"/> und
/// uebersetzt dessen Zustand in Anzeigewerte.
/// </summary>
public sealed partial class MainWindowViewModel : ObservableObject
{
/// <summary>Obergrenze der Terminalzeilen. Aeltere werden vorne verworfen.</summary>
private const int MaxLogLines = 500;
private readonly PredictalyticsHost _host;
private CancellationTokenSource? _workerCts;
private WatchdogHeartbeatService? _watchdog;
/// <summary>Wird gesetzt, sobald das Fenster steht — fuer Dialoge und Fehlermeldungen.</summary>
public Func<string, string, Task>? ShowInfo { get; set; }
public Func<string, string, Task>? ShowError { get; set; }
public Func<string, string, Task<bool>>? ShowConfirm { get; set; }
public PredictalyticsOptions Options { get; }
public ObservableCollection<LogLine> LogLines { get; } = [];
[ObservableProperty] private string _statusText = "";
[ObservableProperty] private string _dbSizeText = "DB Size: —";
[ObservableProperty] private string _buildVersionText = "Build: —";
[ObservableProperty] private string _serverButtonText = "▶ Start Server";
[ObservableProperty] private string _webserverButtonText = "▶ Start Webserver";
[ObservableProperty] private bool _isBusy;
public MainWindowViewModel()
{
Options = PredictalyticsOptions.Load();
_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}";
}
catch
{
BuildVersionText = "Build: Unknown";
}
UpdateStatus();
}
/// <summary>Wird nach dem Serilog-Aufbau aufgerufen.</summary>
public void Start()
{
Log.Information("Shell initialisiert. Bereit.");
Log.Information("'Start Server' beginnt Polling und Discovery.");
Log.Information("'Start Webserver' startet die WebUI auf {Url}", Options.WebserverUrl);
Log.Information("Einstellungen: {Path}", PredictalyticsOptions.SettingsFilePath);
_ = RefreshDbSizeAsync();
var dbSizeTimer = new DispatcherTimer { Interval = TimeSpan.FromHours(6) };
dbSizeTimer.Tick += async (_, _) => await RefreshDbSizeAsync();
dbSizeTimer.Start();
RestartWatchdog();
}
/// <summary>Terminal-Sink: wird von Serilog aus beliebigen Threads gerufen.</summary>
public void AppendLog(string message, LogEventLevel level)
{
var text = message.TrimEnd('\r', '\n');
Dispatcher.UIThread.Post(() =>
{
LogLines.Add(new LogLine(text, level));
while (LogLines.Count > MaxLogLines) LogLines.RemoveAt(0);
});
}
private void UpdateStatus()
{
var workers = _host.WorkersRunning ? "[RUNNING] Workers" : "[STOPPED] Workers";
var web = _host.WebServerRunning ? $"[RUNNING] Webserver :{Options.WebserverPort}" : "[STOPPED] Webserver";
StatusText = $"{workers} | {web}";
ServerButtonText = _host.WorkersRunning ? "⏹ Stop Server" : "▶ Start Server";
WebserverButtonText = _host.WebServerRunning ? "⏹ Stop Webserver" : "▶ Start Webserver";
}
// ─── Watchdog ───
private void RestartWatchdog()
{
_watchdog?.Dispose();
_watchdog = null;
if (!Options.WatchdogEnabled) return;
if (string.IsNullOrWhiteSpace(Options.WatchdogApiKey) || string.IsNullOrWhiteSpace(Options.WatchdogUrl))
{
Log.Information("🐕 Watchdog ist aktiviert, aber URL/API Key fehlen — bitte in den Einstellungen eintragen.");
return;
}
_watchdog = new WatchdogHeartbeatService(
Options.WatchdogUrl,
Options.WatchdogApiKey,
Options.WatchdogSource,
Options.WatchdogInstance,
Options.WatchdogIntervalSeconds,
metadataProvider: () => new
{
workersRunning = _host.WorkersRunning,
webserverRunning = _host.WebServerRunning
});
_watchdog.Start();
}
// ─── Befehle ───
[RelayCommand]
private async Task ToggleServerAsync()
{
if (!_host.WorkersRunning)
{
_workerCts = new CancellationTokenSource();
Log.Information("🚀 Starte Hintergrund-Worker...");
try
{
await _host.StartWorkersAsync(_workerCts.Token);
}
catch (OperationCanceledException) { }
catch (Exception ex) { Log.Error(ex, "Worker error"); }
}
else
{
Log.Information("⏹ Stoppe Hintergrund-Worker...");
_workerCts?.Cancel();
Log.Information("Worker gestoppt.");
}
UpdateStatus();
}
[RelayCommand]
private async Task ToggleWebserverAsync()
{
if (!_host.WebServerRunning)
{
try
{
Log.Information("🌐 Starte Kestrel auf {Url}...", Options.WebserverUrl);
await _host.StartWebServerAsync();
Log.Information("✅ WebUI erreichbar unter {Url}", Options.WebserverUrl);
Log.Information("📄 Swagger unter {Url}/swagger", Options.WebserverUrl);
}
catch (Exception ex)
{
Log.Error(ex, "Webserver konnte nicht gestartet werden");
if (ShowError != null) await ShowError("Fehler", $"Webserver konnte nicht gestartet werden:\n{ex.Message}");
}
}
else
{
Log.Information("⏹ Stoppe Webserver...");
await _host.StopWebServerAsync();
Log.Information("Webserver gestoppt.");
}
UpdateStatus();
}
[RelayCommand]
private void SaveSettings()
{
Options.Save();
RestartWatchdog();
Log.Information("Einstellungen gespeichert: {Path}", PredictalyticsOptions.SettingsFilePath);
UpdateStatus();
}
[RelayCommand]
private void OpenWebUi() => OpenInShell(Options.WebserverUrl);
[RelayCommand]
private void OpenLogFolder()
{
var path = LoggingSetup.DefaultLogDirectory;
OpenInShell(Directory.Exists(path) ? path : AppContext.BaseDirectory);
}
[RelayCommand]
private async Task SyncMarketsAsync()
{
if (!await GuardWorkersStoppedAsync("Der Markt-Sync")) return;
try
{
IsBusy = true;
Log.Information("Manueller Markt-Sync gestartet...");
using var cts = new CancellationTokenSource();
await _host.RunSingleMarketSyncAsync(cts.Token);
Log.Information("Manueller Markt-Sync abgeschlossen.");
if (ShowInfo != null) await ShowInfo("Fertig", "Markt-Sync abgeschlossen.");
}
catch (Exception ex)
{
Log.Error(ex, "Manueller Markt-Sync fehlgeschlagen");
if (ShowError != null) await ShowError("Fehler", $"Markt-Sync fehlgeschlagen:\n{ex.Message}");
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task UpdateDatabaseAsync()
{
if (!await GuardWorkersStoppedAsync("Das Datenbank-Update")) return;
try
{
IsBusy = true;
Log.Information("Manuelles Datenbank-Update gestartet...");
await _host.UpdateDatabaseAsync();
Log.Information("Datenbank aktualisiert.");
if (ShowInfo != null) await ShowInfo("Fertig", "Datenbank-Update abgeschlossen.");
}
catch (Exception ex)
{
Log.Error(ex, "Manuelles Datenbank-Update fehlgeschlagen");
if (ShowError != null) await ShowError("Fehler", $"Datenbank-Update fehlgeschlagen:\n{ex.Message}");
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task RecalculateAllAsync()
{
if (!await GuardWorkersStoppedAsync("Die Neuberechnung")) return;
if (ShowConfirm == null) return;
var confirmed = await ShowConfirm("Alle Trader neu berechnen",
"Dies löscht alle ABGELEITETEN Analysedaten (Positionen, Tages-Snapshots, " +
"Kategorie-Statistiken) und markiert jeden Trader zur vollständigen Neuberechnung.\n\n" +
"Rohdaten (Trades und Märkte) bleiben unberührt.\n\n" +
"Danach den Server starten: der Analytics-Worker baut jeden Trader mit der " +
"aktuellen Engine neu auf (läuft im Hintergrund, kann bei vielen Tradern " +
"mehrere Stunden dauern).\n\nFortfahren?");
if (!confirmed) return;
try
{
IsBusy = true;
Log.Information("Vollständige Neuberechnung angestoßen...");
using var cts = new CancellationTokenSource();
var summary = await _host.RunRecalculateAllTradersAsync(cts.Token);
if (ShowInfo != null)
await ShowInfo("Alle Trader neu berechnen",
$"Zurücksetzen abgeschlossen:\n\n{summary}\n\nJetzt den Server starten, um die Analytik neu aufzubauen.");
}
catch (Exception ex)
{
Log.Error(ex, "Vollständige Neuberechnung fehlgeschlagen");
if (ShowError != null) await ShowError("Fehler", $"Neuberechnung fehlgeschlagen:\n{ex.Message}");
}
finally { IsBusy = false; }
}
// ─── Hilfsfunktionen ───
private async Task<bool> GuardWorkersStoppedAsync(string action)
{
if (!_host.WorkersRunning) return true;
if (ShowError != null)
await ShowError("Worker aktiv", $"{action} kann nicht laufen, während die Hintergrund-Worker aktiv sind. Bitte zuerst den Server stoppen.");
return false;
}
private async Task RefreshDbSizeAsync()
{
var sizeMb = await _host.GetDatabaseSizeMbAsync();
DbSizeText = sizeMb.HasValue ? $"DB Size: {sizeMb.Value:F2} MB" : "DB Size: —";
}
/// <summary>
/// Oeffnet Pfad oder URL mit der Standardanwendung des Systems.
/// UseShellExecute funktioniert unter Windows wie unter Linux (dort ueber xdg-open).
/// </summary>
private static void OpenInShell(string target)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(target) { UseShellExecute = true });
}
catch (Exception ex)
{
Log.Error(ex, "Konnte {Target} nicht öffnen", target);
}
}
/// <summary>Beim Beenden: Watchdog abmelden, Worker und Webserver stoppen.</summary>
public void Shutdown()
{
_watchdog?.NotifyStopping();
_watchdog?.Dispose();
_watchdog = null;
_workerCts?.Cancel();
try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ }
}
}
@@ -0,0 +1,19 @@
using MySqlConnector;
namespace Predictalytics.Shell.ViewModels;
/// <summary>Feste Auswahllisten fuer die Einstellungsansicht.</summary>
public static class OptionSources
{
/// <summary>
/// Die praktisch relevanten SSL-Modi. Die uebrigen Werte des Enums
/// (VerifyCA, VerifyFull) erfordern hinterlegte Zertifikate und wuerden
/// hier nur zu Fehlkonfiguration einladen.
/// </summary>
public static MySqlSslMode[] SslModes { get; } =
[
MySqlSslMode.None,
MySqlSslMode.Preferred,
MySqlSslMode.Required
];
}
+79
View File
@@ -0,0 +1,79 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
namespace Predictalytics.Shell.Views;
/// <summary>
/// Minimale Ersatzdialoge fuer <c>MessageBox.Show</c>. Bewusst im Code aufgebaut
/// und ohne Drittanbieterpaket — es sind nur zwei Varianten noetig.
/// </summary>
public static class Dialogs
{
public static Task ShowInfoAsync(Window owner, string title, string message) =>
ShowAsync(owner, title, message, ["OK"]).ContinueWith(_ => { });
public static Task ShowErrorAsync(Window owner, string title, string message) =>
ShowAsync(owner, title, message, ["OK"]).ContinueWith(_ => { });
public static async Task<bool> ShowConfirmAsync(Window owner, string title, string message) =>
await ShowAsync(owner, title, message, ["Ja", "Abbrechen"]) == 0;
private static Task<int> ShowAsync(Window owner, string title, string message, string[] buttons)
{
var tcs = new TaskCompletionSource<int>();
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8
};
var dialog = new Window
{
Title = title,
SizeToContent = SizeToContent.WidthAndHeight,
MaxWidth = 640,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
CanResize = false,
ShowInTaskbar = false
};
for (var i = 0; i < buttons.Length; i++)
{
var index = i;
var button = new Button
{
Content = buttons[i],
MinWidth = 90,
IsDefault = index == 0,
IsCancel = index == buttons.Length - 1
};
button.Click += (_, _) => { tcs.TrySetResult(index); dialog.Close(); };
buttonPanel.Children.Add(button);
}
dialog.Content = new StackPanel
{
Margin = new Avalonia.Thickness(20),
Spacing = 16,
Children =
{
new TextBlock
{
Text = message,
TextWrapping = TextWrapping.Wrap,
MaxWidth = 580
},
buttonPanel
}
};
// Schliessen ueber das Fensterkreuz zaehlt als Abbruch.
dialog.Closed += (_, _) => tcs.TrySetResult(buttons.Length - 1);
_ = dialog.ShowDialog(owner);
return tcs.Task;
}
}
@@ -0,0 +1,33 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Predictalytics.Shell.Views.LicenseWindow"
Title="Predictalytics — Lizenzaktivierung"
Width="560" SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen">
<StackPanel Margin="24" Spacing="16">
<TextBlock TextWrapping="Wrap"
Text="Diese Installation benötigt eine gültige Lizenz." FontSize="15" FontWeight="SemiBold" />
<TextBlock TextWrapping="Wrap" Opacity="0.8"
Text="Bitte Lizenzschlüssel eingeben (Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX):" />
<TextBox Name="KeyBox"
FontFamily="Cascadia Code,DejaVu Sans Mono,Consolas,monospace"
FontSize="14"
Watermark="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" />
<TextBlock Name="StatusText" TextWrapping="Wrap" MinHeight="34" IsVisible="False" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="12">
<ProgressBar Name="Busy" IsIndeterminate="True" Width="100"
VerticalAlignment="Center" IsVisible="False" />
<Button Name="ActivateButton" Content="Aktivieren" MinWidth="110" IsDefault="True"
Click="OnActivateClick" />
<Button Content="Beenden" MinWidth="100" IsCancel="True" Click="OnExitClick" />
</StackPanel>
</StackPanel>
</Window>
@@ -0,0 +1,91 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using LicenseLabrador.Client;
using Predictalytics.Hosting;
namespace Predictalytics.Shell.Views;
/// <summary>
/// Aktivierungsfenster, wenn keine nutzbare Lizenz vorliegt.
/// <see cref="Completion"/> liefert true, sobald eine gueltige, pruefsummen-
/// verifizierte Lizenz vorliegt; false, wenn der Benutzer abbricht.
/// </summary>
public partial class LicenseWindow : Window
{
private readonly LicenseClient _client = null!;
private readonly TaskCompletionSource<bool> _completion = new();
/// <summary>Wird abgeschlossen, wenn aktiviert oder abgebrochen wurde.</summary>
public Task<bool> Completion => _completion.Task;
public LicenseWindow() => InitializeComponent();
public LicenseWindow(LicenseClient client, LicenseResult? lastResult) : this()
{
_client = client;
if (lastResult != null && lastResult.State != LicenseState.NoLicense)
{
SetStatus($"Letzte Prüfung: {lastResult.State} — {lastResult.Message}", isError: true);
}
}
private async void OnActivateClick(object? sender, RoutedEventArgs e)
{
var key = KeyBox.Text?.Trim().ToUpperInvariant() ?? "";
if (string.IsNullOrWhiteSpace(key))
{
SetStatus("Bitte einen Lizenzschlüssel eingeben.", isError: true);
return;
}
ActivateButton.IsEnabled = false;
Busy.IsVisible = true;
SetStatus("Prüfe Lizenz am Server…", isError: false);
try
{
var result = await _client.ValidateAsync(key);
if (LicenseGuard.IsUsable(_client, result))
{
_completion.TrySetResult(true);
Close();
return;
}
SetStatus($"Lizenz nicht nutzbar ({result.State}):\n{result.Message}", isError: true);
}
catch (Exception ex)
{
SetStatus($"Fehler bei der Prüfung: {ex.Message}", isError: true);
}
finally
{
ActivateButton.IsEnabled = true;
Busy.IsVisible = false;
}
}
private void OnExitClick(object? sender, RoutedEventArgs e)
{
_completion.TrySetResult(false);
Close();
}
protected override void OnClosed(EventArgs e)
{
// Schliessen ueber das Fensterkreuz zaehlt als Abbruch.
_completion.TrySetResult(false);
base.OnClosed(e);
}
private void SetStatus(string message, bool isError)
{
StatusText.Text = message;
StatusText.IsVisible = true;
StatusText.Foreground = isError
? new SolidColorBrush(Color.FromRgb(205, 92, 92))
: new SolidColorBrush(Color.FromRgb(150, 150, 150));
}
}
@@ -0,0 +1,218 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Predictalytics.Shell.ViewModels"
xmlns:hosting="using:Predictalytics.Hosting"
xmlns:sql="using:MySqlConnector"
xmlns:sys="using:System"
x:Class="Predictalytics.Shell.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="Predictalytics Analytics — Backend Server"
Width="1400" Height="900"
MinWidth="900" MinHeight="600"
WindowStartupLocation="CenterScreen">
<Window.Resources>
<!-- Monospace mit Fallback-Kette: Cascadia gibt es unter Linux meist nicht. -->
<FontFamily x:Key="TerminalFont">Cascadia Code,DejaVu Sans Mono,Liberation Mono,Consolas,monospace</FontFamily>
</Window.Resources>
<Window.Styles>
<Style Selector="TextBlock.label">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,12,0" />
</Style>
<Style Selector="TextBlock.hint">
<Setter Property="Opacity" Value="0.6" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Margin" Value="0,2,0,0" />
</Style>
<Style Selector="HeaderedContentControl.group">
<Setter Property="Margin" Value="0,0,0,20" />
</Style>
</Window.Styles>
<DockPanel>
<!-- ─── Menü ─── -->
<Menu DockPanel.Dock="Top">
<MenuItem Header="_Datei">
<MenuItem Header="_Beenden" Click="OnExitClick" />
</MenuItem>
<MenuItem Header="_Ansicht">
<MenuItem Header="Log-Ordner öffnen" Command="{Binding OpenLogFolderCommand}" />
<MenuItem Header="WebUI öffnen" Command="{Binding OpenWebUiCommand}" />
</MenuItem>
<MenuItem Header="_Entwicklung">
<MenuItem Header="Märkte synchronisieren" Command="{Binding SyncMarketsCommand}" />
<MenuItem Header="Datenbank aktualisieren" Command="{Binding UpdateDatabaseCommand}" />
<Separator />
<MenuItem Header="Alle Trader neu berechnen…" Command="{Binding RecalculateAllCommand}" />
</MenuItem>
</Menu>
<!-- ─── Werkzeugleiste ─── -->
<Border DockPanel.Dock="Top" Padding="8" BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="{Binding ServerButtonText}" Command="{Binding ToggleServerCommand}" MinWidth="150" />
<Button Content="{Binding WebserverButtonText}" Command="{Binding ToggleWebserverCommand}" MinWidth="170" />
<ProgressBar IsIndeterminate="True" Width="120" VerticalAlignment="Center"
IsVisible="{Binding IsBusy}" />
</StackPanel>
</Border>
<!-- ─── Statusleiste ─── -->
<Border DockPanel.Dock="Bottom" Padding="8,4" BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusText}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Text="{Binding DbSizeText}" Margin="16,0" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding BuildVersionText}" VerticalAlignment="Center" Opacity="0.7" />
</Grid>
</Border>
<!-- ─── Inhalt ─── -->
<TabControl>
<TabItem Header="Terminal">
<ListBox Name="LogList"
ItemsSource="{Binding LogLines}"
Background="#0F0F14"
FontFamily="{StaticResource TerminalFont}"
FontSize="12"
SelectionMode="Multiple">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LogLine">
<TextBlock Text="{Binding Text}"
Foreground="{Binding Foreground}"
TextWrapping="NoWrap" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</TabItem>
<TabItem Header="Einstellungen">
<Grid RowDefinitions="*,Auto">
<ScrollViewer Grid.Row="0" Padding="20">
<StackPanel MaxWidth="760" HorizontalAlignment="Left"
DataContext="{Binding Options}" x:DataType="hosting:PredictalyticsOptions">
<!-- Webserver -->
<HeaderedContentControl Classes="group" Header="Webserver">
<StackPanel Spacing="12" Margin="0,8,0,0">
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Port" />
<NumericUpDown Grid.Column="1" Value="{Binding WebserverPort}"
Minimum="1" Maximum="65535" FormatString="0" HorizontalAlignment="Left" Width="160" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Bind-Adresse" />
<StackPanel Grid.Column="1">
<TextBox Text="{Binding WebserverHost}" Width="260" HorizontalAlignment="Left" />
<TextBlock Classes="hint"
Text="'localhost' = nur lokal erreichbar. '0.0.0.0' macht die API von außen erreichbar — dann unbedingt absichern." />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Datenbank-Debug" />
<CheckBox Grid.Column="1" IsChecked="{Binding DbConnectionDebug}"
Content="Ausführliche Verbindungsinfos im Terminal" />
</Grid>
</StackPanel>
</HeaderedContentControl>
<!-- Datenbank -->
<HeaderedContentControl Classes="group" Header="Datenbank">
<StackPanel Spacing="12" Margin="0,8,0,0">
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Server" />
<TextBox Grid.Column="1" Text="{Binding DbServer}" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Datenbank" />
<TextBox Grid.Column="1" Text="{Binding DbName}" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Benutzer" />
<TextBox Grid.Column="1" Text="{Binding DbUser}" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Passwort" />
<TextBox Grid.Column="1" Text="{Binding DbPassword}" PasswordChar="•" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="SSL-Modus" />
<StackPanel Grid.Column="1">
<ComboBox SelectedItem="{Binding DbSslMode}" Width="200" HorizontalAlignment="Left"
ItemsSource="{x:Static vm:OptionSources.SslModes}" />
<TextBlock Classes="hint"
Text="Bei entfernten Servern 'Preferred' empfohlen. 'None' überträgt unverschlüsselt." />
</StackPanel>
</Grid>
</StackPanel>
</HeaderedContentControl>
<!-- Watchdog -->
<HeaderedContentControl Classes="group" Header="Watchdog">
<StackPanel Spacing="12" Margin="0,8,0,0">
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Aktiv" />
<CheckBox Grid.Column="1" IsChecked="{Binding WatchdogEnabled}"
Content="Heartbeats senden (Dead-Man's-Switch)" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Server-URL" />
<TextBox Grid.Column="1" Text="{Binding WatchdogUrl}" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="API Key" />
<TextBox Grid.Column="1" Text="{Binding WatchdogApiKey}" PasswordChar="•" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Source" />
<TextBox Grid.Column="1" Text="{Binding WatchdogSource}" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Instanz" />
<TextBox Grid.Column="1" Text="{Binding WatchdogInstance}" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Intervall (Sek.)" />
<NumericUpDown Grid.Column="1" Value="{Binding WatchdogIntervalSeconds}"
Minimum="15" Maximum="3600" FormatString="0" HorizontalAlignment="Left" Width="160" />
</Grid>
</StackPanel>
</HeaderedContentControl>
<!-- Egress -->
<HeaderedContentControl Classes="group" Header="Egress (Proxy / Quell-IP)">
<StackPanel Spacing="8" Margin="0,8,0,0">
<TextBlock Classes="hint"
Text="Ein Kanal pro Zeile im Format: id|type|value&#10;Beispiel: prox-1|Proxy|http://user:pass@proxy:8080&#10; ip-1|SourceIp|192.168.1.100" />
<TextBox Text="{Binding EgressChannelsText}"
AcceptsReturn="True"
TextWrapping="NoWrap"
MinHeight="120"
FontFamily="{StaticResource TerminalFont}"
FontSize="12" />
</StackPanel>
</HeaderedContentControl>
</StackPanel>
</ScrollViewer>
<Border Grid.Row="1" Padding="20,12" BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Orientation="Horizontal" Spacing="12">
<Button Content="Speichern &amp; übernehmen" Command="{Binding SaveSettingsCommand}" />
<TextBlock VerticalAlignment="Center" Opacity="0.6"
Text="Port und Datenbank wirken beim nächsten Start des Webservers." />
</StackPanel>
</Border>
</Grid>
</TabItem>
</TabControl>
</DockPanel>
</Window>
@@ -0,0 +1,56 @@
using System.Collections.Specialized;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
using Predictalytics.Shell.ViewModels;
namespace Predictalytics.Shell.Views;
public partial class MainWindow : Window
{
private MainWindowViewModel? _vm;
public MainWindow()
{
InitializeComponent();
}
public MainWindow(MainWindowViewModel viewModel) : this()
{
_vm = viewModel;
DataContext = viewModel;
// Dialoge kann nur das Fenster zeigen — das ViewModel bekommt sie als Rueckrufe.
viewModel.ShowInfo = (title, message) => Dialogs.ShowInfoAsync(this, title, message);
viewModel.ShowError = (title, message) => Dialogs.ShowErrorAsync(this, title, message);
viewModel.ShowConfirm = (title, message) => Dialogs.ShowConfirmAsync(this, title, message);
// Terminal mitlaufen lassen.
viewModel.LogLines.CollectionChanged += OnLogLinesChanged;
}
private void OnLogLinesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action != NotifyCollectionChangedAction.Add) return;
// Nach dem Layout ans Ende scrollen, sonst existiert der Container noch nicht.
Dispatcher.UIThread.Post(() =>
{
var list = this.FindControl<ListBox>("LogList");
if (list?.ItemCount > 0) list.ScrollIntoView(list.ItemCount - 1);
}, DispatcherPriority.Background);
}
private void OnExitClick(object? sender, RoutedEventArgs e) => Close();
protected override void OnClosed(EventArgs e)
{
if (_vm != null)
{
_vm.LogLines.CollectionChanged -= OnLogLinesChanged;
_vm.Shutdown();
_vm = null;
}
base.OnClosed(e);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Predictalytics.Shell"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:asm.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>