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:
@@ -57,6 +57,20 @@
|
|||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||||
</ItemGroup>
|
</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 -->
|
<!-- Test -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<Project Path="src/Predictalytics.Domain/Predictalytics.Domain.csproj" />
|
<Project Path="src/Predictalytics.Domain/Predictalytics.Domain.csproj" />
|
||||||
<Project Path="src/Predictalytics.Hosting/Predictalytics.Hosting.csproj" />
|
<Project Path="src/Predictalytics.Hosting/Predictalytics.Hosting.csproj" />
|
||||||
<Project Path="src/Predictalytics.Infrastructure/Predictalytics.Infrastructure.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.WinFormsHost/Predictalytics.WinFormsHost.csproj" />
|
||||||
<Project Path="src/Predictalytics.Worker/Predictalytics.Worker.csproj" />
|
<Project Path="src/Predictalytics.Worker/Predictalytics.Worker.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|||||||
@@ -298,7 +298,68 @@ ausschließlich UI-Code. `Predictalytics.Hosting` baut ohne Windows-Referenzen.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Phase 3 — Avalonia-Bedienhülle (4–6 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`
|
### AP 3.1 — Projekt `Predictalytics.Shell`
|
||||||
```xml
|
```xml
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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 Beispiel: prox-1|Proxy|http://user:pass@proxy:8080 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 & ü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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user