From 1131d257cfeee12b907498045991064fab2e3992 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 7 Sep 2026 21:09:03 +0200 Subject: [PATCH] =?UTF-8?q?Eigenst=C3=A4ndiges=20token-gesch=C3=BCtztes=20?= =?UTF-8?q?Verwaltungs-WebGUI=20(Admin=20WebGUI)=20hinzugef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unabhängige Kestrel-Instanz auf Port 5001 (vollständig getrennt vom Analyse-Webserver) - Admin API Endpunkte ohne Swagger-Exposition - Token-Gatekeeper mit sofortigem Login-Overlay bei unautorisiertem Zugriff - Live-Terminal via SSE-Log-Streaming und Log-Download-Übersicht - Steuerung von Workern, Analyse-Webserver, Wartungsaktionen und Einstellungen - Nahtlose Integration in Avalonia Desktop-Shell und Linux Headless-Modus - Unit-Tests für Admin-Filter, Optionen und Log-Puffer --- STATUS.md | 2 +- src/Predictalytics.Api/appsettings.json | 6 + .../Hosting/AdminAuthFilterTests.cs | 100 ++ .../Hosting/AdminOptionsTests.cs | 113 +++ .../Admin/AdminApiEndpoints.cs | 577 ++++++++++++ .../Admin/AdminAuthFilter.cs | 68 ++ .../Admin/AdminWebServer.cs | 149 +++ .../Admin/InMemoryLogBuffer.cs | 152 +++ .../Admin/wwwroot/css/admin.css | 868 ++++++++++++++++++ .../Admin/wwwroot/index.html | 498 ++++++++++ .../Admin/wwwroot/js/admin.js | 734 +++++++++++++++ src/Predictalytics.Hosting/LoggingSetup.cs | 3 + .../PredictalyticsHost.cs | 125 ++- .../PredictalyticsOptions.cs | 87 +- .../Predictalytics.Shell.csproj | 4 + .../Services/HeadlessRunner.cs | 10 + .../ViewModels/MainWindowViewModel.cs | 43 +- .../Views/MainWindow.axaml | 33 +- .../Views/MainWindow.axaml.cs | 10 + 19 files changed, 3566 insertions(+), 16 deletions(-) create mode 100644 src/Predictalytics.Application.Tests/Hosting/AdminAuthFilterTests.cs create mode 100644 src/Predictalytics.Application.Tests/Hosting/AdminOptionsTests.cs create mode 100644 src/Predictalytics.Hosting/Admin/AdminApiEndpoints.cs create mode 100644 src/Predictalytics.Hosting/Admin/AdminAuthFilter.cs create mode 100644 src/Predictalytics.Hosting/Admin/AdminWebServer.cs create mode 100644 src/Predictalytics.Hosting/Admin/InMemoryLogBuffer.cs create mode 100644 src/Predictalytics.Hosting/Admin/wwwroot/css/admin.css create mode 100644 src/Predictalytics.Hosting/Admin/wwwroot/index.html create mode 100644 src/Predictalytics.Hosting/Admin/wwwroot/js/admin.js diff --git a/STATUS.md b/STATUS.md index 7138296..2a50ffd 100644 --- a/STATUS.md +++ b/STATUS.md @@ -24,7 +24,7 @@ Der Hauptzweig ist `main`; die Portierung auf .NET 10 und Avalonia ist dort seit | Zielframework | net10.0, ein einziges für alle Projekte | | Oberfläche | Avalonia 12.1.1 (Windows + Linux), zusätzlich ein Headless-Modus | | Datenzugriff | EF Core **9** (bewusst; Pomelo hat keinen EF-10-Provider), 32 Migrationen | -| Tests | **126 grün, 1 bewusst übersprungen** | +| Tests | **147 grün, 1 bewusst übersprungen** | | Build | fehlerfrei und **warnungsfrei** | ### Aufbau diff --git a/src/Predictalytics.Api/appsettings.json b/src/Predictalytics.Api/appsettings.json index 296f069..e01f7a5 100644 --- a/src/Predictalytics.Api/appsettings.json +++ b/src/Predictalytics.Api/appsettings.json @@ -20,5 +20,11 @@ "AuthRequired": false, "AllowedOrigins": [ "http://localhost:5000" ], "ReadOnlyDatabase": false + }, + "Admin": { + "Enabled": true, + "Port": 5001, + "Host": "localhost", + "Token": "" } } diff --git a/src/Predictalytics.Application.Tests/Hosting/AdminAuthFilterTests.cs b/src/Predictalytics.Application.Tests/Hosting/AdminAuthFilterTests.cs new file mode 100644 index 0000000..6ce8f21 --- /dev/null +++ b/src/Predictalytics.Application.Tests/Hosting/AdminAuthFilterTests.cs @@ -0,0 +1,100 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Http; +using Predictalytics.Hosting.Admin; +using Xunit; + +namespace Predictalytics.Application.Tests.Hosting; + +public class AdminAuthFilterTests +{ + private const string SecretToken = "super-secret-admin-token-12345"; + + [Fact] + public async Task Correct_token_via_header_passes() + { + var (context, next, called) = Build(headerToken: SecretToken); + var filter = new AdminAuthFilter(SecretToken); + var result = await filter.InvokeAsync(context, next); + + Assert.True(called.Value); + Assert.Equal("ok", result); + } + + [Fact] + public async Task Correct_token_via_bearer_passes() + { + var (context, next, called) = Build(bearerToken: SecretToken); + var filter = new AdminAuthFilter(SecretToken); + var result = await filter.InvokeAsync(context, next); + + Assert.True(called.Value); + Assert.Equal("ok", result); + } + + [Fact] + public async Task Correct_token_via_query_param_passes() + { + var (context, next, called) = Build(queryToken: SecretToken); + var filter = new AdminAuthFilter(SecretToken); + var result = await filter.InvokeAsync(context, next); + + Assert.True(called.Value); + Assert.Equal("ok", result); + } + + [Theory] + [InlineData("")] + [InlineData("wrong-token")] + [InlineData("super-secret-admin-token-1234")] // one char short + [InlineData("super-secret-admin-token-123456")] // one char long + [InlineData("SUPER-SECRET-ADMIN-TOKEN-12345")] // case mismatch + public async Task Wrong_or_missing_token_is_rejected(string candidate) + { + var (context, next, called) = Build(headerToken: candidate); + var filter = new AdminAuthFilter(SecretToken); + var result = await filter.InvokeAsync(context, next); + + Assert.False(called.Value); + Assert.NotNull(result); + Assert.NotEqual("ok", result); + } + + [Fact] + public async Task Empty_configured_token_rejects_everything() + { + var (context, next, called) = Build(headerToken: "anything"); + var filter = new AdminAuthFilter(""); + var result = await filter.InvokeAsync(context, next); + + Assert.False(called.Value); + Assert.NotNull(result); + Assert.NotEqual("ok", result); + } + + private static (DefaultEndpointFilterInvocationContext Context, EndpointFilterDelegate Next, StrongBox Called) + Build(string? headerToken = null, string? bearerToken = null, string? queryToken = null) + { + var httpContext = new DefaultHttpContext(); + if (!string.IsNullOrEmpty(headerToken)) + { + httpContext.Request.Headers[AdminAuthFilter.HeaderName] = headerToken; + } + if (!string.IsNullOrEmpty(bearerToken)) + { + httpContext.Request.Headers["Authorization"] = "Bearer " + bearerToken; + } + if (!string.IsNullOrEmpty(queryToken)) + { + httpContext.Request.QueryString = new QueryString("?token=" + queryToken); + } + + var called = new StrongBox(false); + EndpointFilterDelegate next = _ => + { + called.Value = true; + return ValueTask.FromResult("ok"); + }; + + return (new DefaultEndpointFilterInvocationContext(httpContext), next, called); + } +} diff --git a/src/Predictalytics.Application.Tests/Hosting/AdminOptionsTests.cs b/src/Predictalytics.Application.Tests/Hosting/AdminOptionsTests.cs new file mode 100644 index 0000000..96a03ae --- /dev/null +++ b/src/Predictalytics.Application.Tests/Hosting/AdminOptionsTests.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.Configuration; +using Predictalytics.Hosting; +using Predictalytics.Hosting.Admin; +using Serilog.Events; +using Serilog.Parsing; +using Xunit; + +namespace Predictalytics.Application.Tests.Hosting; + +public class AdminOptionsTests +{ + [Theory] + [InlineData("localhost", false)] + [InlineData("LocalHost", false)] + [InlineData("127.0.0.1", false)] + [InlineData("::1", false)] + [InlineData("0.0.0.0", true)] + [InlineData("192.168.1.50", true)] + [InlineData("predictalytics.local", true)] + public void IsAdminPubliclyBound_detects_network_binding(string host, bool expectedPublic) + { + var options = new PredictalyticsOptions { AdminServerHost = host }; + Assert.Equal(expectedPublic, options.IsAdminPubliclyBound); + } + + [Fact] + public void AdminServerUrl_combines_host_and_port() + { + var options = new PredictalyticsOptions { AdminServerHost = "192.168.1.10", AdminServerPort = 5005 }; + Assert.Equal("http://192.168.1.10:5005", options.AdminServerUrl); + } + + [Fact] + public void GenerateSecureToken_creates_valid_hex_token() + { + var token1 = PredictalyticsOptions.GenerateSecureToken(); + var token2 = PredictalyticsOptions.GenerateSecureToken(); + + Assert.False(string.IsNullOrWhiteSpace(token1)); + Assert.False(string.IsNullOrWhiteSpace(token2)); + Assert.NotEqual(token1, token2); + Assert.True(token1.Length >= 32); + } + + [Fact] + public void ApplyConfigurationOverrides_updates_admin_settings() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Admin:Enabled"] = "true", + ["Admin:Port"] = "5099", + ["Admin:Host"] = "0.0.0.0", + ["Admin:Token"] = "override-token-from-config" + }) + .Build(); + + var options = new PredictalyticsOptions + { + AdminServerPort = 5001, + AdminServerHost = "localhost", + AdminToken = "initial-token" + }; + + options.ApplyConfigurationOverrides(config); + + Assert.Equal(5099, options.AdminServerPort); + Assert.Equal("0.0.0.0", options.AdminServerHost); + Assert.Equal("override-token-from-config", options.AdminToken); + } + + [Fact] + public void InMemoryLogBuffer_stores_and_caps_entries() + { + var buffer = new InMemoryLogBuffer(maxCapacity: 5); + var emitted = new List(); + buffer.LogEmitted += entry => emitted.Add(entry); + + var parser = new MessageTemplateParser(); + for (int i = 1; i <= 10; i++) + { + var logEvent = new LogEvent( + DateTimeOffset.UtcNow, + LogEventLevel.Information, + null, + parser.Parse($"Message {i}"), + []); + buffer.Append(logEvent); + } + + var recent = buffer.GetRecent(count: 100); + Assert.Equal(5, recent.Count); + Assert.Equal("Message 6", recent[0].Message); + Assert.Equal("Message 10", recent[4].Message); + Assert.Equal(10, emitted.Count); + } + + [Fact] + public void InMemoryLogBuffer_filters_by_level() + { + var parser = new MessageTemplateParser(); + var buffer = new InMemoryLogBuffer(maxCapacity: 10); + + buffer.Append(new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Information, null, parser.Parse("Info msg"), [])); + buffer.Append(new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Warning, null, parser.Parse("Warn msg"), [])); + buffer.Append(new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, null, parser.Parse("Error msg"), [])); + + var warnOrHigher = buffer.GetRecent(10, minLevel: LogEventLevel.Warning); + Assert.Equal(2, warnOrHigher.Count); + Assert.Equal("Warn msg", warnOrHigher[0].Message); + Assert.Equal("Error msg", warnOrHigher[1].Message); + } +} diff --git a/src/Predictalytics.Hosting/Admin/AdminApiEndpoints.cs b/src/Predictalytics.Hosting/Admin/AdminApiEndpoints.cs new file mode 100644 index 0000000..cef07ef --- /dev/null +++ b/src/Predictalytics.Hosting/Admin/AdminApiEndpoints.cs @@ -0,0 +1,577 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Predictalytics.Hosting; +using Serilog; +using Serilog.Events; + +namespace Predictalytics.Hosting.Admin; + +/// +/// Registriert alle API-Endpunkte für die Fernadministration der Software. +/// Diese Endpunkte werden NUR auf dem separaten Admin-Port gemappt und sind +/// im öffentlichen Analyse-Swagger nicht sichtbar. +/// +public static class AdminApiEndpoints +{ + private static readonly DateTime StartTime = DateTime.UtcNow; + + public static void MapAdminApi(this IEndpointRouteBuilder routes, PredictalyticsHost host) + { + var adminGroup = routes.MapGroup("/api/admin"); + + // ───────────────────────────────────────────────────────────────────── + // Authentifizierung (Token-Validierung) + // ───────────────────────────────────────────────────────────────────── + adminGroup.MapPost("/auth/verify", (HttpContext context, TokenVerifyRequest? body) => + { + var expected = host.Options.AdminToken?.Trim() ?? ""; + if (string.IsNullOrEmpty(expected)) + { + return Results.Json(new { valid = false, message = "Auf dem Server ist kein Admin-Token konfiguriert." }, statusCode: 401); + } + + string? candidate = null; + if (context.Request.Headers.TryGetValue(AdminAuthFilter.HeaderName, out var headerVal)) + { + candidate = headerVal.ToString(); + } + else if (!string.IsNullOrWhiteSpace(body?.Token)) + { + candidate = body.Token; + } + else if (context.Request.Query.TryGetValue("token", out var qv)) + { + candidate = qv.ToString(); + } + + if (string.IsNullOrWhiteSpace(candidate) || !CryptographicTokenEquals(candidate, expected)) + { + return Results.Json(new { valid = false, message = "Ungültiger Admin-Token." }, statusCode: 401); + } + + return Results.Ok(new + { + valid = true, + version = DcConfig.AppVersion, + host = host.Options.AdminServerHost, + port = host.Options.AdminServerPort + }); + }); + + // Alle nachfolgenden Routen verlangen zwingend den gültigen Admin-Token + var secureGroup = adminGroup.AddEndpointFilter(new AdminAuthFilter(() => host.Options.AdminToken)); + + // ───────────────────────────────────────────────────────────────────── + // Status & Übersicht + // ───────────────────────────────────────────────────────────────────── + secureGroup.MapGet("/status", async (CancellationToken ct) => + { + var dbSize = await host.GetDatabaseSizeMbAsync(ct); + var memoryMb = Math.Round(GC.GetTotalMemory(false) / 1024.0 / 1024.0, 1); + var uptime = DateTime.UtcNow - StartTime; + + return Results.Ok(new + { + workersRunning = host.WorkersRunning, + webServerRunning = host.WebServerRunning, + webServerUrl = host.Options.WebserverUrl, + webServerPort = host.Options.WebserverPort, + webServerHost = host.Options.WebserverHost, + adminServerRunning = host.AdminServerRunning, + adminServerUrl = host.Options.AdminServerUrl, + adminServerPort = host.Options.AdminServerPort, + adminServerHost = host.Options.AdminServerHost, + isBusy = host.IsBusy, + busyOperation = host.BusyOperation, + dbSizeMb = dbSize, + appVersion = DcConfig.AppVersion, + gitCommit = DcConfig.GitCommitShort, + memoryMb = memoryMb, + uptimeSeconds = Math.Round(uptime.TotalSeconds, 0), + uptimeText = FormatUptime(uptime) + }); + }); + + // ───────────────────────────────────────────────────────────────────── + // Steuerung (Worker & Analyse-Webserver) + // ───────────────────────────────────────────────────────────────────── + secureGroup.MapPost("/workers/start", () => + { + if (host.WorkersRunning) + return Results.Ok(new { ok = true, message = "Worker laufen bereits.", workersRunning = true }); + + host.StartWorkers(); + Log.Information("Admin WebGUI: Hintergrund-Worker gestartet."); + return Results.Ok(new { ok = true, message = "Hintergrund-Worker gestartet.", workersRunning = true }); + }); + + secureGroup.MapPost("/workers/stop", () => + { + if (!host.WorkersRunning) + return Results.Ok(new { ok = true, message = "Worker sind bereits gestoppt.", workersRunning = false }); + + host.StopWorkers(); + Log.Information("Admin WebGUI: Hintergrund-Worker gestoppt."); + return Results.Ok(new { ok = true, message = "Hintergrund-Worker gestoppt.", workersRunning = false }); + }); + + secureGroup.MapPost("/webserver/start", async () => + { + if (host.WebServerRunning) + return Results.Ok(new { ok = true, message = "Analyse-Webserver läuft bereits.", webServerRunning = true }); + + try + { + await host.StartWebServerAsync(); + Log.Information("Admin WebGUI: Analyse-Webserver gestartet auf {Url}", host.Options.WebserverUrl); + return Results.Ok(new { ok = true, message = "Analyse-Webserver gestartet.", webServerRunning = true, url = host.Options.WebserverUrl }); + } + catch (Exception ex) + { + Log.Error(ex, "Admin WebGUI: Starten des Analyse-Webservers fehlgeschlagen"); + return Results.BadRequest(new { ok = false, error = ex.Message }); + } + }); + + secureGroup.MapPost("/webserver/stop", async () => + { + if (!host.WebServerRunning) + return Results.Ok(new { ok = true, message = "Analyse-Webserver ist bereits gestoppt.", webServerRunning = false }); + + await host.StopWebServerAsync(); + Log.Information("Admin WebGUI: Analyse-Webserver gestoppt."); + return Results.Ok(new { ok = true, message = "Analyse-Webserver gestoppt.", webServerRunning = false }); + }); + + // ───────────────────────────────────────────────────────────────────── + // Wartungsaktionen + // ───────────────────────────────────────────────────────────────────── + secureGroup.MapPost("/maintenance/sync-markets", async (CancellationToken ct) => + { + if (host.WorkersRunning) + return Results.Conflict(new { error = "Markt-Sync kann nicht laufen, während die Worker aktiv sind. Bitte zuerst Worker stoppen." }); + + try + { + await host.RunGuardedMaintenanceAsync("Manueller Markt-Sync", async token => + { + await host.RunSingleMarketSyncAsync(token); + return true; + }, ct); + + return Results.Ok(new { ok = true, message = "Manueller Markt-Sync erfolgreich abgeschlossen." }); + } + catch (Exception ex) + { + return Results.BadRequest(new { ok = false, error = ex.Message }); + } + }); + + secureGroup.MapPost("/maintenance/update-database", async (CancellationToken ct) => + { + if (host.WorkersRunning) + return Results.Conflict(new { error = "Datenbank-Update kann nicht laufen, während die Worker aktiv sind." }); + + try + { + await host.RunGuardedMaintenanceAsync("Datenbank-Update", async _ => + { + await host.UpdateDatabaseAsync(); + return true; + }, ct); + + return Results.Ok(new { ok = true, message = "Datenbank erfolgreich aktualisiert." }); + } + catch (Exception ex) + { + return Results.BadRequest(new { ok = false, error = ex.Message }); + } + }); + + secureGroup.MapPost("/maintenance/recalculate-traders", async (ConfirmActionRequest? body, CancellationToken ct) => + { + if (body?.Confirm != true) + { + return Results.BadRequest(new { error = "Bestätigung erforderlich. Bitte { 'confirm': true } mitsenden." }); + } + + if (host.WorkersRunning) + return Results.Conflict(new { error = "Neuberechnung kann nicht laufen, während die Worker aktiv sind." }); + + try + { + var summary = await host.RunGuardedMaintenanceAsync("Alle Trader neu berechnen", token => + host.RunRecalculateAllTradersAsync(token), ct); + + return Results.Ok(new { ok = true, message = "Trader-Neuberechnung abgeschlossen.", summary }); + } + catch (Exception ex) + { + return Results.BadRequest(new { ok = false, error = ex.Message }); + } + }); + + // ───────────────────────────────────────────────────────────────────── + // Logs & Terminal + // ───────────────────────────────────────────────────────────────────── + secureGroup.MapGet("/logs", (int? count, string? minLevel) => + { + var limit = Math.Clamp(count ?? 200, 10, 1000); + LogEventLevel? parsedLevel = null; + if (!string.IsNullOrWhiteSpace(minLevel) && Enum.TryParse(minLevel, true, out var l)) + { + parsedLevel = l; + } + + var entries = InMemoryLogBuffer.Instance.GetRecent(limit, parsedLevel); + return Results.Ok(entries); + }); + + // SSE (Server-Sent Events) Stream für Live-Terminal + secureGroup.MapGet("/logs/stream", async (HttpContext ctx, CancellationToken ct) => + { + ctx.Response.Headers.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleWriter = false, + SingleReader = true + }); + + void OnLog(AdminLogEntry entry) => channel.Writer.TryWrite(entry); + + InMemoryLogBuffer.Instance.LogEmitted += OnLog; + + try + { + // Initiale Initialisierungsmeldung + await ctx.Response.WriteAsync("event: connected\ndata: {}\n\n", ct); + await ctx.Response.Body.FlushAsync(ct); + + // Zuerst die letzten 50 Zeilen senden + var recent = InMemoryLogBuffer.Instance.GetRecent(50); + foreach (var entry in recent) + { + var json = JsonSerializer.Serialize(entry); + await ctx.Response.WriteAsync($"data: {json}\n\n", ct); + } + await ctx.Response.Body.FlushAsync(ct); + + // Live weiter streamen + while (!ct.IsCancellationRequested) + { + var entry = await channel.Reader.ReadAsync(ct); + var json = JsonSerializer.Serialize(entry); + await ctx.Response.WriteAsync($"data: {json}\n\n", ct); + await ctx.Response.Body.FlushAsync(ct); + } + } + catch (OperationCanceledException) { } + finally + { + InMemoryLogBuffer.Instance.LogEmitted -= OnLog; + channel.Writer.TryComplete(); + } + }); + + // Log-Dateien auflisten + secureGroup.MapGet("/logs/files", () => + { + var logDir = LoggingSetup.DefaultLogDirectory; + if (!Directory.Exists(logDir)) + return Results.Ok(Array.Empty()); + + var files = Directory.EnumerateFiles(logDir, "*.log", SearchOption.AllDirectories) + .Select(path => + { + var fileInfo = new FileInfo(path); + var relativePath = Path.GetRelativePath(logDir, path).Replace('\\', '/'); + var category = Path.GetDirectoryName(relativePath)?.Replace('\\', '/') ?? "root"; + return new + { + name = Path.GetFileName(path), + relativePath = relativePath, + category = string.IsNullOrEmpty(category) ? "root" : category, + sizeBytes = fileInfo.Length, + sizeKb = Math.Round(fileInfo.Length / 1024.0, 1), + modifiedUtc = fileInfo.LastWriteTimeUtc + }; + }) + .OrderByDescending(f => f.modifiedUtc) + .ToList(); + + return Results.Ok(files); + }); + + // Log-Datei herunterladen + secureGroup.MapGet("/logs/files/download", (string file) => + { + if (string.IsNullOrWhiteSpace(file)) + return Results.BadRequest("Dateiname erforderlich."); + + var logDir = Path.GetFullPath(LoggingSetup.DefaultLogDirectory); + var safePath = Path.GetFullPath(Path.Combine(logDir, file.TrimStart('/', '\\'))); + + if (!safePath.StartsWith(logDir, StringComparison.OrdinalIgnoreCase) || !File.Exists(safePath)) + { + return Results.NotFound("Datei nicht gefunden oder ungültiger Pfad."); + } + + return Results.File(safePath, "text/plain", Path.GetFileName(safePath)); + }); + + // ───────────────────────────────────────────────────────────────────── + // Einstellungen (Settings) + // ───────────────────────────────────────────────────────────────────── + secureGroup.MapGet("/settings", () => + { + var opts = host.Options; + return Results.Ok(new + { + webserver = new + { + port = opts.WebserverPort, + host = opts.WebserverHost, + apiControlToken = string.IsNullOrEmpty(opts.ApiControlToken) ? "" : "••••••••", + hasApiControlToken = !string.IsNullOrEmpty(opts.ApiControlToken), + dbConnectionDebug = opts.DbConnectionDebug + }, + adminServer = new + { + enabled = opts.AdminServerEnabled, + port = opts.AdminServerPort, + host = opts.AdminServerHost, + token = opts.AdminToken, + url = opts.AdminServerUrl + }, + database = new + { + server = opts.DbServer, + database = opts.DbName, + user = opts.DbUser, + hasPassword = !string.IsNullOrEmpty(opts.DbPassword), + password = string.IsNullOrEmpty(opts.DbPassword) ? "" : "••••••••", + sslMode = opts.DbSslMode.ToString() + }, + deploymentCenter = new + { + serverUrl = opts.DcServerUrl, + token = string.IsNullOrEmpty(opts.DcToken) ? "" : "••••••••", + hasToken = !string.IsNullOrEmpty(opts.DcToken), + heartbeatEnabled = opts.DcHeartbeatEnabled, + source = opts.DcSource, + instance = opts.DcInstance, + intervalSeconds = opts.DcHeartbeatIntervalSeconds, + errorReportingEnabled = opts.DcErrorReportingEnabled, + updateCheckEnabled = opts.DcUpdateCheckEnabled, + updateChannel = opts.DcUpdateChannel + }, + egress = new + { + channelsText = opts.EgressChannelsText + } + }); + }); + + secureGroup.MapPost("/settings", (UpdateSettingsRequest body) => + { + var opts = host.Options; + + if (body.Webserver != null) + { + if (body.Webserver.Port.HasValue && body.Webserver.Port > 0) + opts.WebserverPort = body.Webserver.Port.Value; + if (!string.IsNullOrWhiteSpace(body.Webserver.Host)) + opts.WebserverHost = body.Webserver.Host.Trim(); + if (body.Webserver.ApiControlToken != null && body.Webserver.ApiControlToken != "••••••••") + opts.ApiControlToken = body.Webserver.ApiControlToken.Trim(); + if (body.Webserver.DbConnectionDebug.HasValue) + opts.DbConnectionDebug = body.Webserver.DbConnectionDebug.Value; + } + + if (body.AdminServer != null) + { + if (body.AdminServer.Enabled.HasValue) + opts.AdminServerEnabled = body.AdminServer.Enabled.Value; + if (body.AdminServer.Port.HasValue && body.AdminServer.Port > 0) + opts.AdminServerPort = body.AdminServer.Port.Value; + if (!string.IsNullOrWhiteSpace(body.AdminServer.Host)) + opts.AdminServerHost = body.AdminServer.Host.Trim(); + if (!string.IsNullOrWhiteSpace(body.AdminServer.Token)) + opts.AdminToken = body.AdminServer.Token.Trim(); + } + + if (body.Database != null) + { + if (!string.IsNullOrWhiteSpace(body.Database.Server)) + opts.DbServer = body.Database.Server.Trim(); + if (!string.IsNullOrWhiteSpace(body.Database.Database)) + opts.DbName = body.Database.Database.Trim(); + if (!string.IsNullOrWhiteSpace(body.Database.User)) + opts.DbUser = body.Database.User.Trim(); + if (body.Database.Password != null && body.Database.Password != "••••••••") + opts.DbPassword = body.Database.Password.Trim(); + if (!string.IsNullOrWhiteSpace(body.Database.SslMode) && + Enum.TryParse(body.Database.SslMode, true, out var ssl)) + { + opts.DbSslMode = ssl; + } + } + + if (body.DeploymentCenter != null) + { + if (body.DeploymentCenter.Token != null && body.DeploymentCenter.Token != "••••••••") + opts.DcToken = body.DeploymentCenter.Token.Trim(); + if (body.DeploymentCenter.HeartbeatEnabled.HasValue) + opts.DcHeartbeatEnabled = body.DeploymentCenter.HeartbeatEnabled.Value; + if (!string.IsNullOrWhiteSpace(body.DeploymentCenter.Source)) + opts.DcSource = body.DeploymentCenter.Source.Trim(); + if (!string.IsNullOrWhiteSpace(body.DeploymentCenter.Instance)) + opts.DcInstance = body.DeploymentCenter.Instance.Trim(); + if (body.DeploymentCenter.IntervalSeconds.HasValue && body.DeploymentCenter.IntervalSeconds >= 15) + opts.DcHeartbeatIntervalSeconds = body.DeploymentCenter.IntervalSeconds.Value; + if (body.DeploymentCenter.ErrorReportingEnabled.HasValue) + opts.DcErrorReportingEnabled = body.DeploymentCenter.ErrorReportingEnabled.Value; + if (body.DeploymentCenter.UpdateCheckEnabled.HasValue) + opts.DcUpdateCheckEnabled = body.DeploymentCenter.UpdateCheckEnabled.Value; + if (!string.IsNullOrWhiteSpace(body.DeploymentCenter.UpdateChannel)) + opts.DcUpdateChannel = body.DeploymentCenter.UpdateChannel.Trim(); + } + + if (body.Egress?.ChannelsText != null) + { + opts.EgressChannelsText = body.Egress.ChannelsText; + } + + opts.Save(); + DcErrorReporter.Configure(opts.DcToken, opts.DcErrorReportingEnabled); + Log.Information("Admin WebGUI: Einstellungen gespeichert."); + + return Results.Ok(new { ok = true, message = "Einstellungen erfolgreich gespeichert." }); + }); + + secureGroup.MapPost("/settings/generate-token", () => + { + var newToken = PredictalyticsOptions.GenerateSecureToken(); + return Results.Ok(new { token = newToken }); + }); + + // ───────────────────────────────────────────────────────────────────── + // Deployment Center & Lizenz + // ───────────────────────────────────────────────────────────────────── + secureGroup.MapGet("/license", async () => + { + var hwInfo = LicenseGuard.GetHardwareInfo(); + var check = await LicenseGuard.TryUseCachedAsync(); + + return Results.Ok(new + { + productSlug = DcConfig.ProductSlug, + version = DcConfig.AppVersion, + gitCommit = DcConfig.GitCommitShort, + serverUrl = DcConfig.BaseUrl, + hardwareId = hwInfo.HardwareId, + hwidSource = hwInfo.HwidSource, + status = check.LastResult?.Status ?? "unbekannt", + isUsable = check.IsUsable, + isCached = check.LastResult?.IsCached ?? false, + message = check.LastResult?.Message ?? "", + cacheExpiresAt = check.LastResult?.CacheExpiresAt + }); + }); + + secureGroup.MapPost("/updates/check", async () => + { + var check = await LicenseGuard.TryUseCachedAsync(); + var licenseKey = check.Session?.LicenseKey; + var result = await DcUpdateService.CheckAsync(host.Options.DcUpdateChannel, licenseKey); + + return Results.Ok(new + { + currentVersion = DcConfig.AppVersion, + updateAvailable = result.UpdateAvailable, + latestVersion = result.LatestRelease?.Version ?? DcConfig.AppVersion, + isCritical = result.IsCritical, + changelog = result.LatestRelease?.Changelog, + unauthorized = result.Unauthorized, + message = result.Message + }); + }); + } + + private static bool CryptographicTokenEquals(string candidate, string expected) + { + var candidateBytes = System.Text.Encoding.UTF8.GetBytes(candidate.Trim()); + var expectedBytes = System.Text.Encoding.UTF8.GetBytes(expected.Trim()); + return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(candidateBytes, expectedBytes); + } + + private static string FormatUptime(TimeSpan span) + { + if (span.TotalDays >= 1) + return $"{(int)span.TotalDays}d {span.Hours}h {span.Minutes}m"; + if (span.TotalHours >= 1) + return $"{span.Hours}h {span.Minutes}m {span.Seconds}s"; + return $"{span.Minutes}m {span.Seconds}s"; + } +} + +// Request Models +public sealed record TokenVerifyRequest(string? Token); +public sealed record ConfirmActionRequest(bool? Confirm); +public sealed class UpdateSettingsRequest +{ + public WebserverSettingsUpdate? Webserver { get; set; } + public AdminServerSettingsUpdate? AdminServer { get; set; } + public DatabaseSettingsUpdate? Database { get; set; } + public DcSettingsUpdate? DeploymentCenter { get; set; } + public EgressSettingsUpdate? Egress { get; set; } +} + +public sealed class WebserverSettingsUpdate +{ + public int? Port { get; set; } + public string? Host { get; set; } + public string? ApiControlToken { get; set; } + public bool? DbConnectionDebug { get; set; } +} + +public sealed class AdminServerSettingsUpdate +{ + public bool? Enabled { get; set; } + public int? Port { get; set; } + public string? Host { get; set; } + public string? Token { get; set; } +} + +public sealed class DatabaseSettingsUpdate +{ + public string? Server { get; set; } + public string? Database { get; set; } + public string? User { get; set; } + public string? Password { get; set; } + public string? SslMode { get; set; } +} + +public sealed class DcSettingsUpdate +{ + public string? Token { get; set; } + public bool? HeartbeatEnabled { get; set; } + public string? Source { get; set; } + public string? Instance { get; set; } + public int? IntervalSeconds { get; set; } + public bool? ErrorReportingEnabled { get; set; } + public bool? UpdateCheckEnabled { get; set; } + public string? UpdateChannel { get; set; } +} + +public sealed class EgressSettingsUpdate +{ + public string? ChannelsText { get; set; } +} diff --git a/src/Predictalytics.Hosting/Admin/AdminAuthFilter.cs b/src/Predictalytics.Hosting/Admin/AdminAuthFilter.cs new file mode 100644 index 0000000..5a7d4cc --- /dev/null +++ b/src/Predictalytics.Hosting/Admin/AdminAuthFilter.cs @@ -0,0 +1,68 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; + +namespace Predictalytics.Hosting.Admin; + +/// +/// Endpunkt-Filter zur Sicherung der Administrations-APIs über einen gemeinsamen Token. +/// Prüft entweder den Header 'X-Admin-Token' oder das Bearer-Token im 'Authorization'-Header. +/// +public sealed class AdminAuthFilter : IEndpointFilter +{ + public const string HeaderName = "X-Admin-Token"; + private readonly Func _tokenProvider; + + public AdminAuthFilter(string token) => _tokenProvider = () => token; + public AdminAuthFilter(Func tokenProvider) => _tokenProvider = tokenProvider; + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var expectedToken = _tokenProvider()?.Trim() ?? ""; + + // Wenn überhaupt kein Token konfiguriert ist, ist das System ungesichert -> Zugriff verweigern! + if (string.IsNullOrEmpty(expectedToken)) + { + return Results.Json( + new { error = "unauthorized", message = "Auf dem Server ist kein Admin-Token konfiguriert. Zugriff gesperrt." }, + statusCode: StatusCodes.Status401Unauthorized); + } + + var http = context.HttpContext; + string? provided = null; + + if (http.Request.Headers.TryGetValue(HeaderName, out var headerVal)) + { + provided = headerVal.ToString(); + } + else if (http.Request.Headers.TryGetValue("Authorization", out var authVal)) + { + var authStr = authVal.ToString(); + if (authStr.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + provided = authStr["Bearer ".Length..].Trim(); + } + } + else if (http.Request.Query.TryGetValue("token", out var queryVal)) + { + provided = queryVal.ToString(); + } + + if (!IsMatch(provided, expectedToken)) + { + return Results.Json( + new { error = "unauthorized", message = "Admin-Token fehlt oder ist ungültig." }, + statusCode: StatusCodes.Status401Unauthorized); + } + + return await next(context); + } + + private static bool IsMatch(string? provided, string expected) + { + if (string.IsNullOrEmpty(provided)) return false; + var actualBytes = Encoding.UTF8.GetBytes(provided.Trim()); + var expectedBytes = Encoding.UTF8.GetBytes(expected.Trim()); + return CryptographicOperations.FixedTimeEquals(actualBytes, expectedBytes); + } +} diff --git a/src/Predictalytics.Hosting/Admin/AdminWebServer.cs b/src/Predictalytics.Hosting/Admin/AdminWebServer.cs new file mode 100644 index 0000000..974c69c --- /dev/null +++ b/src/Predictalytics.Hosting/Admin/AdminWebServer.cs @@ -0,0 +1,149 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace Predictalytics.Hosting.Admin; + +/// +/// Kestrel-Webserver für das eigenständige Verwaltungs-WebGUI (Admin WebGUI). +/// Läuft unabhängig vom Analyse-Webserver auf eigenem Port und bindet die Admin-API ein. +/// Swagger ist hier bewusst deaktiviert. +/// +public sealed class AdminWebServer : IAsyncDisposable +{ + private readonly PredictalyticsHost _host; + private WebApplication? _app; + private Task? _runTask; + private CancellationTokenSource? _cts; + private readonly object _lock = new(); + + public bool IsRunning { get; private set; } + + public AdminWebServer(PredictalyticsHost host) + { + _host = host; + } + + public async Task StartAsync() + { + lock (_lock) + { + if (_app != null) return; + } + + await Task.Run(async () => + { + var options = _host.Options; + var url = options.AdminServerUrl; + + Log.Information("Starting Admin Kestrel webserver on {Url}...", url); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls(url); + builder.Host.UseSerilog(); + + // CORS: Erlaube die eigene Admin-URL sowie ggf. konfigurierte Origins + builder.Services.AddCors(cors => cors.AddDefaultPolicy(p => + p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); + + var app = builder.Build(); + app.UseCors(); + + // Statische Dateien des Admin-WebGUIs aus admin_wwwroot + var wwwroot = FindAdminWwwrootPath(); + if (wwwroot != null && Directory.Exists(wwwroot)) + { + Log.Information("Admin WebGUI: Serving static assets from {Path}", wwwroot); + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(wwwroot), + OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache" + }); + + app.MapGet("/", () => Results.File(Path.Combine(wwwroot, "index.html"), "text/html")); + } + else + { + Log.Warning("Admin WebGUI: admin_wwwroot directory not found! Root will serve a fallback status page."); + app.MapGet("/", () => Results.Content( + "" + + "

Predictalytics Admin Server

Assets werden geladen oder admin_wwwroot fehlt.

", + "text/html")); + } + + // Admin API-Endpunkte registrieren (KEIN Swagger) + app.MapAdminApi(_host); + + lock (_lock) { _app = app; } + _cts = new CancellationTokenSource(); + _runTask = app.RunAsync(_cts.Token); + + IsRunning = true; + Log.Information("Admin WebGUI active and listening on {Url}", url); + }); + } + + public async Task StopAsync() + { + WebApplication? app; + lock (_lock) + { + app = _app; + _app = null; + } + + if (app != null) + { + _cts?.Cancel(); + try + { + await app.StopAsync(TimeSpan.FromSeconds(5)); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Log.Warning(ex, "Exception while stopping Admin Kestrel webserver"); + } + await ((IAsyncDisposable)app).DisposeAsync(); + Log.Information("Admin Kestrel webserver stopped."); + } + + IsRunning = false; + } + + public static string? FindAdminWwwrootPath() + { + var baseDir = AppContext.BaseDirectory; + var candidates = new[] + { + Path.Combine(baseDir, "admin_wwwroot"), + Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "Predictalytics.Hosting", "Admin", "wwwroot")), + Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "src", "Predictalytics.Hosting", "Admin", "wwwroot")), + Path.Combine(baseDir, "Admin", "wwwroot") + }; + + foreach (var path in candidates) + { + var full = Path.GetFullPath(path); + if (Directory.Exists(full)) + { + return full; + } + } + + return null; + } + + public async ValueTask DisposeAsync() + { + await StopAsync(); + } +} diff --git a/src/Predictalytics.Hosting/Admin/InMemoryLogBuffer.cs b/src/Predictalytics.Hosting/Admin/InMemoryLogBuffer.cs new file mode 100644 index 0000000..b6cc25c --- /dev/null +++ b/src/Predictalytics.Hosting/Admin/InMemoryLogBuffer.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Serilog.Core; +using Serilog.Events; + +namespace Predictalytics.Hosting.Admin; + +/// +/// Repräsentiert einen einzelnen Log-Eintrag im Ringspeicher. +/// +public sealed record AdminLogEntry( + long Id, + DateTimeOffset Timestamp, + LogEventLevel Level, + string LevelText, + string Message, + string? Exception = null +); + +/// +/// Thread-sicherer In-Memory-Ringspeicher für Log-Einträge. +/// Ermöglicht Webclients das Abfragen historischer Logs sowie das +/// Live-Streaming über Server-Sent Events (SSE). +/// +public sealed class InMemoryLogBuffer +{ + private static readonly Lazy _instance = new(() => new InMemoryLogBuffer()); + public static InMemoryLogBuffer Instance => _instance.Value; + + private readonly object _lock = new(); + private readonly int _maxCapacity; + private readonly LinkedList _entries = new(); + private long _nextId = 1; + + public event Action? LogEmitted; + + public InMemoryLogBuffer(int maxCapacity = 1000) + { + _maxCapacity = maxCapacity; + } + + /// + /// Fügt einen neuen Log-Eintrag hinzu und benachrichtigt verbundene Listener. + /// + public void Append(LogEvent logEvent) + { + AdminLogEntry entry; + lock (_lock) + { + var id = _nextId++; + var levelText = logEvent.Level switch + { + LogEventLevel.Verbose => "VRB", + LogEventLevel.Debug => "DBG", + LogEventLevel.Information => "INF", + LogEventLevel.Warning => "WRN", + LogEventLevel.Error => "ERR", + LogEventLevel.Fatal => "FTL", + _ => "LOG" + }; + + var message = logEvent.RenderMessage(); + if (string.IsNullOrEmpty(message)) + { + message = logEvent.MessageTemplate?.Text ?? ""; + } + var exText = logEvent.Exception?.ToString(); + + entry = new AdminLogEntry( + id, + logEvent.Timestamp, + logEvent.Level, + levelText, + message, + exText + ); + + _entries.AddLast(entry); + while (_entries.Count > _maxCapacity) + { + _entries.RemoveFirst(); + } + } + + try + { + LogEmitted?.Invoke(entry); + } + catch + { + // Listener-Fehler dürfen den Logger niemals blockieren + } + } + + /// + /// Liefert die letzten N Einträge absteigend oder aufsteigend. + /// + public IReadOnlyList GetRecent(int count = 200, LogEventLevel? minLevel = null) + { + lock (_lock) + { + IEnumerable query = _entries; + if (minLevel.HasValue) + { + query = query.Where(e => e.Level >= minLevel.Value); + } + + return query.TakeLast(count).ToList(); + } + } + + /// + /// Liefert Einträge, die nach einer bestimmten ID eingetroffen sind (für Polling-Fallback). + /// + public IReadOnlyList GetSince(long afterId, int limit = 200, LogEventLevel? minLevel = null) + { + lock (_lock) + { + IEnumerable query = _entries.Where(e => e.Id > afterId); + if (minLevel.HasValue) + { + query = query.Where(e => e.Level >= minLevel.Value); + } + + return query.Take(limit).ToList(); + } + } + + public void Clear() + { + lock (_lock) + { + _entries.Clear(); + } + } +} + +/// +/// Serilog-Sink, der Ausgaben in den schreibt. +/// +public sealed class InMemoryLogBufferSink : ILogEventSink +{ + private readonly InMemoryLogBuffer _buffer; + + public InMemoryLogBufferSink(InMemoryLogBuffer? buffer = null) + { + _buffer = buffer ?? InMemoryLogBuffer.Instance; + } + + public void Emit(LogEvent logEvent) => _buffer.Append(logEvent); +} diff --git a/src/Predictalytics.Hosting/Admin/wwwroot/css/admin.css b/src/Predictalytics.Hosting/Admin/wwwroot/css/admin.css new file mode 100644 index 0000000..338b1bf --- /dev/null +++ b/src/Predictalytics.Hosting/Admin/wwwroot/css/admin.css @@ -0,0 +1,868 @@ +/* Predictalytics Administration WebGUI Stylesheet */ + +:root { + --bg-app: #0c0d12; + --bg-sidebar: #12131a; + --bg-card: #171923; + --bg-input: #10121a; + --bg-terminal: #090a0f; + --border: #232738; + --border-focus: #3b82f6; + --text-main: #f1f5f9; + --text-muted: #94a3b8; + --text-dim: #64748b; + --primary: #2563eb; + --primary-hover: #1d4ed8; + --primary-light: #3b82f6; + --success: #10b981; + --warning: #f59e0b; + --danger: #ef4444; + --danger-hover: #dc2626; + --font-mono: "Cascadia Code", "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background-color: var(--bg-app); + color: var(--text-main); + line-height: 1.5; + font-size: 14px; + overflow: hidden; + height: 100vh; +} + +/* ─── TYPOGRAPHY & BUTTONS ─── */ +h1, h2, h3 { + font-weight: 600; + color: #fff; +} + +.font-mono { + font-family: var(--font-mono); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 14px; + font-size: 13px; + font-weight: 500; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s ease; + background: transparent; + color: var(--text-main); + text-decoration: none; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background-color: var(--primary); + color: #fff; + border-color: var(--primary); +} +.btn-primary:hover:not(:disabled) { + background-color: var(--primary-hover); +} + +.btn-secondary { + background-color: #232738; + border-color: #33394d; + color: var(--text-main); +} +.btn-secondary:hover:not(:disabled) { + background-color: #2e344a; +} + +.btn-outline { + border-color: #3b4259; + background: rgba(255, 255, 255, 0.03); +} +.btn-outline:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + border-color: #525c7a; +} + +.btn-warning { + background-color: #b45309; + color: #fff; +} +.btn-warning:hover:not(:disabled) { + background-color: #92400e; +} + +.btn-danger { + background-color: var(--danger); + color: #fff; +} +.btn-danger:hover:not(:disabled) { + background-color: var(--danger-hover); +} + +.btn-sm { + padding: 5px 10px; + font-size: 12px; +} + +.btn-large { + padding: 10px 20px; + font-size: 14px; + font-weight: 600; +} + +.btn-block { + width: 100%; +} + +/* ─── LOGIN OVERLAY ─── */ +.login-modal { + position: fixed; + inset: 0; + background: rgba(8, 9, 13, 0.95); + backdrop-filter: blur(8px); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; +} + +.login-card { + width: 100%; + max-width: 440px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 32px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6); +} + +.login-header { + text-align: center; + margin-bottom: 24px; +} + +.login-shield { + font-size: 42px; + margin-bottom: 8px; +} + +.login-header h1 { + font-size: 24px; + letter-spacing: -0.5px; +} + +.login-badge { + display: inline-block; + background: #1e293b; + color: var(--primary-light); + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + padding: 2px 8px; + border-radius: 4px; + margin: 6px 0 12px; +} + +.login-hint { + color: var(--text-muted); + font-size: 13px; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--text-muted); + margin-bottom: 6px; +} + +.form-group input { + width: 100%; + padding: 10px 12px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + color: #fff; + font-size: 14px; + outline: none; + font-family: var(--font-mono); +} + +.form-group input:focus { + border-color: var(--border-focus); +} + +.login-error { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.4); + color: #fca5a5; + padding: 10px 12px; + border-radius: 6px; + font-size: 13px; + margin-bottom: 16px; +} + +/* ─── APP LAYOUT ─── */ +.app-layout { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* TOPBAR */ +.topbar { + height: 52px; + background: var(--bg-sidebar); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + flex-shrink: 0; +} + +.topbar-left { + display: flex; + align-items: center; + gap: 20px; +} + +.brand { + display: flex; + align-items: center; + gap: 8px; +} + +.brand-title { + font-size: 16px; + font-weight: 700; + color: #fff; +} + +.badge-admin { + background: #ea580c; + color: #fff; + font-size: 10px; + font-weight: 800; + padding: 2px 6px; + border-radius: 4px; + letter-spacing: 0.5px; +} + +.status-indicators { + display: flex; + align-items: center; + gap: 10px; +} + +.status-pill { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + padding: 4px 10px; + border-radius: 20px; + color: var(--text-muted); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--text-dim); +} + +.status-pill.status-running .status-dot { + background-color: var(--success); + box-shadow: 0 0 8px rgba(16, 185, 129, 0.6); +} + +.status-pill.status-stopped .status-dot { + background-color: #64748b; +} + +.busy-pill { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + background: rgba(245, 158, 11, 0.15); + border: 1px solid rgba(245, 158, 11, 0.4); + color: #fcd34d; + padding: 4px 12px; + border-radius: 20px; +} + +.spinner { + width: 12px; + height: 12px; + border: 2px solid rgba(245, 158, 11, 0.3); + border-top-color: #f59e0b; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.topbar-right { + display: flex; + align-items: center; + gap: 10px; +} + +.topbar-divider { + width: 1px; + height: 20px; + background: var(--border); + margin: 0 4px; +} + +.version-tag { + font-size: 12px; + color: var(--text-dim); + font-family: var(--font-mono); +} + +/* WORKSPACE */ +.workspace { + display: flex; + flex: 1; + overflow: hidden; +} + +/* SIDEBAR */ +.sidebar { + width: 220px; + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.nav-menu { + padding: 12px 8px; + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} + +.nav-link { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: 6px; + color: var(--text-muted); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: all 0.15s ease; +} + +.nav-link:hover { + background: rgba(255, 255, 255, 0.04); + color: #fff; +} + +.nav-link.active { + background: #1e293b; + color: var(--primary-light); + font-weight: 600; +} + +.nav-icon { + font-size: 16px; + width: 20px; + text-align: center; +} + +.sidebar-footer { + padding: 12px 16px; + border-top: 1px solid var(--border); +} + +.system-mini-info { + font-size: 11px; + color: var(--text-dim); + display: flex; + flex-direction: column; + gap: 2px; +} + +.system-mini-info strong { + color: var(--text-muted); +} + +/* CONTENT AREA */ +.content-area { + flex: 1; + overflow-y: auto; + padding: 24px 32px; + background: var(--bg-app); +} + +.tab-panel { + display: none; +} + +.tab-panel.active { + display: block; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.panel-header h2 { + font-size: 20px; +} + +/* ─── STAT CARDS ─── */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 18px 20px; +} + +.stat-title { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +.stat-value { + font-size: 24px; + font-weight: 700; + color: #fff; + margin-bottom: 4px; +} + +.stat-desc { + font-size: 12px; + color: var(--text-dim); + margin-bottom: 14px; +} + +.stat-actions { + display: flex; + gap: 8px; +} + +.section-box { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px; + margin-bottom: 24px; +} + +.section-box h3 { + font-size: 15px; + margin-bottom: 14px; +} + +.actions-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +/* ─── TERMINAL ─── */ +.terminal-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.toggle-control { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); + cursor: pointer; +} + +.form-select-sm { + background: #1e2230; + color: #fff; + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + outline: none; +} + +.terminal-wrapper { + background: var(--bg-terminal); + border: 1px solid var(--border); + border-radius: 8px; + height: calc(100vh - 160px); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.terminal-body { + flex: 1; + overflow-y: auto; + padding: 12px 16px; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; +} + +.log-line { + white-space: pre-wrap; + word-break: break-all; +} + +.log-inf { color: #94a3b8; } +.log-wrn { color: #f59e0b; } +.log-err { color: #ef4444; font-weight: 600; } +.log-ftl { color: #ec4899; font-weight: 700; } +.log-dbg { color: #64748b; } +.log-vrb { color: #475569; } + +/* ─── LOG DATEIEN TABELLE ─── */ +.section-hint { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 16px; +} + +.table-container { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.data-table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +.data-table th { + background: #13151f; + padding: 10px 16px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + border-bottom: 1px solid var(--border); +} + +.data-table td { + padding: 10px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + font-size: 13px; +} + +.data-table tr:hover td { + background: rgba(255, 255, 255, 0.02); +} + +.data-table td.num { + text-align: right; + font-family: var(--font-mono); +} + +.data-table td.actions-col { + text-align: right; +} + +.loading-cell { + text-align: center; + padding: 30px !important; + color: var(--text-dim); +} + +/* ─── WARTUNG ─── */ +.maintenance-grid { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 800px; +} + +.maintenance-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px; + display: flex; + align-items: center; + gap: 20px; +} + +.maintenance-card.danger-border { + border-color: rgba(239, 68, 68, 0.4); +} + +.card-icon { + font-size: 32px; + flex-shrink: 0; +} + +.card-content { + flex: 1; +} + +.card-content h3 { + font-size: 16px; + margin-bottom: 4px; +} + +.card-content p { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 8px; +} + +.card-badge { + display: inline-block; + font-size: 11px; + color: var(--text-dim); +} + +.card-badge.badge-warning { + color: #f59e0b; +} + +.card-action { + flex-shrink: 0; +} + +/* ─── EINSTELLUNGEN FORMULAR ─── */ +.settings-group { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px 24px; + margin-bottom: 20px; + max-width: 840px; +} + +.group-header { + font-size: 14px; + font-weight: 700; + color: #fff; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.form-row { + margin-bottom: 16px; +} + +.form-row:last-child { + margin-bottom: 0; +} + +.form-row label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-main); + margin-bottom: 6px; +} + +.form-row input[type="text"], +.form-row input[type="password"], +.form-row input[type="number"], +.form-row select, +.form-row textarea { + width: 100%; + max-width: 480px; + padding: 8px 12px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + color: #fff; + font-size: 13px; + outline: none; +} + +.form-row textarea { + max-width: 640px; + font-family: var(--font-mono); +} + +.form-row input:focus, +.form-row select:focus, +.form-row textarea:focus { + border-color: var(--border-focus); +} + +.input-disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.input-with-action { + display: flex; + align-items: center; + gap: 8px; + max-width: 480px; +} + +.input-with-action input { + flex: 1; +} + +.input-dual { + display: flex; + gap: 8px; + max-width: 480px; +} + +.checkbox-label { + display: flex !important; + align-items: center; + gap: 8px; + cursor: pointer; + font-weight: normal !important; + color: var(--text-muted) !important; +} + +.field-hint { + display: block; + font-size: 11px; + color: var(--text-dim); + margin-top: 4px; +} + +.form-submit-row { + margin-top: 24px; + max-width: 840px; +} + +/* ─── MODAL ─── */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-card { + width: 100%; + max-width: 520px; + background: var(--bg-card); + border: 1px solid rgba(239, 68, 68, 0.4); + border-radius: 10px; + padding: 24px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.8); +} + +.modal-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.modal-icon { + font-size: 28px; +} + +.modal-header h3 { + font-size: 18px; +} + +.modal-body { + font-size: 13px; + color: var(--text-muted); + line-height: 1.6; + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 24px; +} + +.modal-body strong { + color: #fff; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +/* ─── TOAST NOTIFICATIONS ─── */ +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 10001; + display: flex; + flex-direction: column; + gap: 8px; +} + +.toast { + background: #1e2230; + border: 1px solid var(--border); + color: #fff; + padding: 12px 18px; + border-radius: 6px; + font-size: 13px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); + animation: slideIn 0.2s ease-out; + max-width: 380px; +} + +.toast-success { border-color: var(--success); } +.toast-error { border-color: var(--danger); } +.toast-warning { border-color: var(--warning); } + +@keyframes slideIn { + from { transform: translateX(50px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} diff --git a/src/Predictalytics.Hosting/Admin/wwwroot/index.html b/src/Predictalytics.Hosting/Admin/wwwroot/index.html new file mode 100644 index 0000000..58cc99d --- /dev/null +++ b/src/Predictalytics.Hosting/Admin/wwwroot/index.html @@ -0,0 +1,498 @@ + + + + + + Predictalytics Administration + + + + + + + + + + + + + + + +
+ + + + diff --git a/src/Predictalytics.Hosting/Admin/wwwroot/js/admin.js b/src/Predictalytics.Hosting/Admin/wwwroot/js/admin.js new file mode 100644 index 0000000..7774fb1 --- /dev/null +++ b/src/Predictalytics.Hosting/Admin/wwwroot/js/admin.js @@ -0,0 +1,734 @@ +/** + * Predictalytics Administration WebGUI JavaScript + */ + +const STORAGE_KEY = 'predictalytics_admin_token'; +let currentToken = null; +let statusPollInterval = null; +let terminalEventSource = null; +let allLogs = []; +let maxLogEntries = 600; + +// ─── INITIALISIERUNG & AUTHENTIFIZIERUNG ─── + +document.addEventListener('DOMContentLoaded', () => { + setupNavigation(); + + // Prüfe, ob bereits ein Token in sessionStorage hinterlegt ist + const savedToken = sessionStorage.getItem(STORAGE_KEY) || localStorage.getItem(STORAGE_KEY); + if (savedToken) { + verifyAndUnlock(savedToken); + } else { + showLogin(); + } +}); + +function showLogin(errorMessage = null) { + document.getElementById('login-overlay').style.display = 'flex'; + document.getElementById('app-container').style.display = 'none'; + if (statusPollInterval) clearInterval(statusPollInterval); + if (terminalEventSource) { + terminalEventSource.close(); + terminalEventSource = null; + } + + const errBox = document.getElementById('login-error'); + if (errorMessage) { + errBox.textContent = errorMessage; + errBox.style.display = 'block'; + } else { + errBox.style.display = 'none'; + } + + const tokenInput = document.getElementById('admin-token-input'); + tokenInput.value = ''; + tokenInput.focus(); +} + +async function handleLogin(e) { + e.preventDefault(); + const tokenInput = document.getElementById('admin-token-input'); + const token = tokenInput.value.trim(); + if (!token) return; + + const btn = document.getElementById('login-btn'); + btn.disabled = true; + btn.textContent = 'Prüfe...'; + + try { + const response = await fetch('/api/admin/auth/verify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Admin-Token': token + }, + body: JSON.stringify({ token }) + }); + + if (response.ok) { + currentToken = token; + sessionStorage.setItem(STORAGE_KEY, token); + unlockApp(); + } else { + const data = await response.json().catch(() => ({})); + showLogin(data.message || 'Ungültiger Admin-Token. Zugriff verweigert.'); + } + } catch (err) { + showLogin('Verbindungsfehler zum Admin-Server: ' + err.message); + } finally { + btn.disabled = false; + btn.textContent = 'Entsperren & Anmelden'; + } +} + +async function verifyAndUnlock(token) { + try { + const response = await fetch('/api/admin/auth/verify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Admin-Token': token + }, + body: JSON.stringify({ token }) + }); + + if (response.ok) { + currentToken = token; + unlockApp(); + } else { + sessionStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + showLogin('Sitzung abgelaufen oder Token ungültig.'); + } + } catch (err) { + showLogin('Server nicht erreichbar.'); + } +} + +function unlockApp() { + document.getElementById('login-overlay').style.display = 'none'; + document.getElementById('app-container').style.display = 'flex'; + + // Status sofort laden und Polling starten + refreshStatus(); + statusPollInterval = setInterval(refreshStatus, 3000); + + // Initiales Terminal-Streaming starten + connectTerminalStream(); + + // Log-Dateien & Settings vorbereiten + loadLogFiles(); + loadSettings(); + loadLicenseInfo(); +} + +function handleLogout() { + currentToken = null; + sessionStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + showLogin('Erfolgreich abgemeldet.'); +} + +// ─── API HELPER ─── + +async function api(endpoint, options = {}) { + if (!options.headers) options.headers = {}; + if (currentToken) { + options.headers['X-Admin-Token'] = currentToken; + } + + try { + const res = await fetch(endpoint, options); + if (res.status === 401) { + handleLogout(); + throw new Error('Unauthorized'); + } + return res; + } catch (err) { + console.error('API Error (' + endpoint + '):', err); + throw err; + } +} + +// ─── STATUS & STEUERUNG ─── + +let lastStatus = {}; + +async function refreshStatus() { + try { + const res = await api('/api/admin/status'); + if (!res.ok) return; + + const data = await res.json(); + lastStatus = data; + + // Topbar Status-Pills + const workersPill = document.getElementById('status-workers-pill'); + const workersText = document.getElementById('status-workers-text'); + const btnWorkers = document.getElementById('btn-toggle-workers'); + const dashBtnWorkers = document.getElementById('dash-btn-workers'); + const dashWorkersState = document.getElementById('dash-workers-state'); + + if (data.workersRunning) { + workersPill.className = 'status-pill status-running'; + workersText.textContent = 'Workers: Aktiv'; + btnWorkers.textContent = '⏹ Stop Server'; + dashBtnWorkers.textContent = 'Worker stoppen'; + dashBtnWorkers.className = 'btn btn-sm btn-secondary'; + dashWorkersState.textContent = 'Läuft'; + dashWorkersState.style.color = '#10b981'; + } else { + workersPill.className = 'status-pill status-stopped'; + workersText.textContent = 'Workers: Gestoppt'; + btnWorkers.textContent = '▶ Start Server'; + dashBtnWorkers.textContent = 'Worker starten'; + dashBtnWorkers.className = 'btn btn-sm btn-primary'; + dashWorkersState.textContent = 'Gestoppt'; + dashWorkersState.style.color = '#94a3b8'; + } + + // Webserver Status + const webPill = document.getElementById('status-web-pill'); + const webText = document.getElementById('status-web-text'); + const btnWeb = document.getElementById('btn-toggle-webserver'); + const dashBtnWeb = document.getElementById('dash-btn-web'); + const dashWebState = document.getElementById('dash-web-state'); + const dashWebUrl = document.getElementById('dash-web-url'); + const dashLinkWeb = document.getElementById('dash-link-web'); + + if (data.webServerRunning) { + webPill.className = 'status-pill status-running'; + webText.textContent = 'Webserver :' + data.webServerPort; + btnWeb.textContent = '⏹ Stop Webserver'; + dashBtnWeb.textContent = 'Webserver stoppen'; + dashBtnWeb.className = 'btn btn-sm btn-secondary'; + dashWebState.textContent = 'Läuft :' + data.webServerPort; + dashWebState.style.color = '#10b981'; + dashWebUrl.textContent = data.webServerUrl; + dashLinkWeb.href = data.webServerUrl; + dashLinkWeb.style.display = 'inline-flex'; + } else { + webPill.className = 'status-pill status-stopped'; + webText.textContent = 'Webserver: Gestoppt'; + btnWeb.textContent = '▶ Start Webserver'; + dashBtnWeb.textContent = 'Webserver starten'; + dashBtnWeb.className = 'btn btn-sm btn-primary'; + dashWebState.textContent = 'Gestoppt'; + dashWebState.style.color = '#94a3b8'; + dashWebUrl.textContent = 'Port: ' + data.webServerPort; + dashLinkWeb.style.display = 'none'; + } + + // DB Size + const dbText = document.getElementById('status-dbsize-text'); + const dashDbText = document.getElementById('dash-db-size'); + const dbFormatted = data.dbSizeMb != null ? Number(data.dbSizeMb).toFixed(2) + ' MB' : '—'; + dbText.textContent = dbFormatted; + dashDbText.textContent = dbFormatted; + + // Busy Indicator + const busyIndicator = document.getElementById('busy-indicator'); + const busyText = document.getElementById('busy-operation-text'); + if (data.isBusy) { + busyIndicator.style.display = 'flex'; + busyText.textContent = data.busyOperation || 'Wird ausgeführt...'; + } else { + busyIndicator.style.display = 'none'; + } + + // Version & Uptime + document.getElementById('header-version').textContent = 'v' + data.appVersion; + document.getElementById('sys-memory').textContent = data.memoryMb + ' MB'; + document.getElementById('dash-memory').textContent = data.memoryMb + ' MB'; + document.getElementById('sys-uptime').textContent = data.uptimeText; + document.getElementById('dash-uptime').textContent = 'Uptime: ' + data.uptimeText; + + } catch (e) { + console.warn('Status poll failed:', e); + } +} + +async function toggleWorkers() { + const isRunning = lastStatus.workersRunning; + const action = isRunning ? 'stop' : 'start'; + + try { + const res = await api('/api/admin/workers/' + action, { method: 'POST' }); + const data = await res.json(); + showToast(data.message || 'Worker-Status geändert.', res.ok ? 'success' : 'error'); + refreshStatus(); + } catch (e) { + showToast('Aktion fehlgeschlagen: ' + e.message, 'error'); + } +} + +async function toggleWebserver() { + const isRunning = lastStatus.webServerRunning; + const action = isRunning ? 'stop' : 'start'; + + try { + const res = await api('/api/admin/webserver/' + action, { method: 'POST' }); + const data = await res.json(); + showToast(data.message || 'Webserver-Status geändert.', res.ok ? 'success' : 'error'); + refreshStatus(); + } catch (e) { + showToast('Aktion fehlgeschlagen: ' + e.message, 'error'); + } +} + +// ─── WARTUNGSAKTIONEN ─── + +async function triggerMarketSync() { + if (lastStatus.workersRunning) { + showToast('Markt-Sync kann nicht laufen, während die Worker aktiv sind. Bitte zuerst Worker stoppen.', 'warning'); + return; + } + + showToast('Manueller Markt-Sync gestartet...', 'success'); + refreshStatus(); + + try { + const res = await api('/api/admin/maintenance/sync-markets', { method: 'POST' }); + const data = await res.json(); + if (res.ok) { + showToast(data.message || 'Markt-Sync erfolgreich abgeschlossen.', 'success'); + } else { + showToast(data.error || 'Markt-Sync fehlgeschlagen.', 'error'); + } + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } finally { + refreshStatus(); + } +} + +async function triggerDbUpdate() { + if (lastStatus.workersRunning) { + showToast('Datenbank-Update kann nicht laufen, während die Worker aktiv sind.', 'warning'); + return; + } + + showToast('Datenbank-Update gestartet...', 'success'); + refreshStatus(); + + try { + const res = await api('/api/admin/maintenance/update-database', { method: 'POST' }); + const data = await res.json(); + if (res.ok) { + showToast(data.message || 'Datenbank erfolgreich aktualisiert.', 'success'); + } else { + showToast(data.error || 'Datenbank-Update fehlgeschlagen.', 'error'); + } + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } finally { + refreshStatus(); + } +} + +function openRecalculateModal() { + if (lastStatus.workersRunning) { + showToast('Neuberechnung kann nicht laufen, während die Worker aktiv sind. Bitte zuerst Worker stoppen.', 'warning'); + return; + } + document.getElementById('confirm-modal').style.display = 'flex'; +} + +function closeRecalculateModal() { + document.getElementById('confirm-modal').style.display = 'none'; +} + +async function executeRecalculateAll() { + closeRecalculateModal(); + showToast('Neuberechnung aller Trader eingeleitet...', 'warning'); + refreshStatus(); + + try { + const res = await api('/api/admin/maintenance/recalculate-traders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ confirm: true }) + }); + const data = await res.json(); + if (res.ok) { + showToast('Neuberechnung erfolgreich eingeleitet: ' + (data.summary ? data.summary.split('\n')[0] : 'Fertig'), 'success'); + } else { + showToast(data.error || 'Neuberechnung fehlgeschlagen.', 'error'); + } + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } finally { + refreshStatus(); + } +} + +// ─── LIVE-TERMINAL (SSE) ─── + +function connectTerminalStream() { + if (terminalEventSource) { + terminalEventSource.close(); + } + + const url = '/api/admin/logs/stream?token=' + encodeURIComponent(currentToken); + terminalEventSource = new EventSource(url); + + terminalEventSource.onmessage = (e) => { + try { + const entry = JSON.parse(e.data); + if (entry && entry.Message) { + appendLogLine(entry); + } + } catch (err) { + // Ignoriere Ping / Non-JSON + } + }; + + terminalEventSource.onerror = (e) => { + console.warn('Terminal EventSource error / disconnected.'); + }; +} + +function reconnectTerminalStream() { + appendLogLine({ + LevelText: 'INF', + Message: 'Verbinde neu mit Live-Log-Stream...', + Timestamp: new Date().toISOString() + }); + connectTerminalStream(); +} + +function appendLogLine(entry) { + allLogs.push(entry); + if (allLogs.length > maxLogEntries) { + allLogs.shift(); + } + + renderLogLine(entry); +} + +function renderLogLine(entry) { + const filter = document.getElementById('term-level-filter').value; + if (!matchesFilter(entry.LevelText, filter)) return; + + const termBody = document.getElementById('terminal-body'); + const div = document.createElement('div'); + const lvl = (entry.LevelText || 'INF').toLowerCase(); + div.className = 'log-line log-' + lvl; + + const ts = entry.Timestamp ? new Date(entry.Timestamp).toLocaleTimeString('de-DE') : ''; + let text = `[${ts}] [${entry.LevelText || 'INF'}] ${entry.Message}`; + if (entry.Exception) { + text += `\n ⚠ ${entry.Exception}`; + } + div.textContent = text; + termBody.appendChild(div); + + // Auf 500 Zeilen begrenzen + while (termBody.children.length > 500) { + termBody.removeChild(termBody.firstChild); + } + + const autoscroll = document.getElementById('term-autoscroll').checked; + if (autoscroll) { + termBody.scrollTop = termBody.scrollHeight; + } +} + +function filterTerminalLogs() { + const termBody = document.getElementById('terminal-body'); + termBody.innerHTML = ''; + const filter = document.getElementById('term-level-filter').value; + + allLogs.forEach(entry => { + if (matchesFilter(entry.LevelText, filter)) { + renderLogLine(entry); + } + }); +} + +function matchesFilter(levelText, filter) { + if (filter === 'ALL') return true; + if (filter === 'INF') return ['INF', 'WRN', 'ERR', 'FTL'].includes(levelText); + if (filter === 'WRN') return ['WRN', 'ERR', 'FTL'].includes(levelText); + if (filter === 'ERR') return ['ERR', 'FTL'].includes(levelText); + return true; +} + +function clearTerminal() { + allLogs = []; + document.getElementById('terminal-body').innerHTML = '
Terminal geleert.
'; +} + +// ─── LOG-DATEIEN ─── + +async function loadLogFiles() { + const tbody = document.getElementById('logfiles-table-body'); + tbody.innerHTML = 'Lade Log-Dateien...'; + + try { + const res = await api('/api/admin/logs/files'); + if (!res.ok) throw new Error('Fehler beim Laden'); + const files = await res.json(); + + if (!files || files.length === 0) { + tbody.innerHTML = 'Keine Log-Dateien gefunden.'; + return; + } + + tbody.innerHTML = ''; + files.forEach(f => { + const tr = document.createElement('tr'); + const dateStr = f.modifiedUtc ? new Date(f.modifiedUtc).toLocaleString('de-DE') : '—'; + const dlUrl = `/api/admin/logs/files/download?file=${encodeURIComponent(f.relativePath)}&token=${encodeURIComponent(currentToken)}`; + + tr.innerHTML = ` + ${escapeHtml(f.category)} + ${escapeHtml(f.name)} + ${f.sizeKb} KB + ${dateStr} + + ⬇️ Herunterladen + + `; + tbody.appendChild(tr); + }); + } catch (e) { + tbody.innerHTML = 'Log-Dateien konnten nicht geladen werden.'; + } +} + +// ─── EINSTELLUNGEN ─── + +async function loadSettings() { + try { + const res = await api('/api/admin/settings'); + if (!res.ok) return; + const data = await res.json(); + + // Admin + if (data.adminServer) { + document.getElementById('cfg-admin-port').value = data.adminServer.port || 5001; + document.getElementById('cfg-admin-host').value = data.adminServer.host || 'localhost'; + document.getElementById('cfg-admin-token').value = data.adminServer.token || ''; + } + + // Webserver + if (data.webserver) { + document.getElementById('cfg-web-port').value = data.webserver.port || 5000; + document.getElementById('cfg-web-host').value = data.webserver.host || 'localhost'; + document.getElementById('cfg-web-token').value = data.webserver.apiControlToken || ''; + document.getElementById('cfg-web-dbdebug').checked = !!data.webserver.dbConnectionDebug; + } + + // Datenbank + if (data.database) { + document.getElementById('cfg-db-server').value = data.database.server || ''; + document.getElementById('cfg-db-name').value = data.database.database || ''; + document.getElementById('cfg-db-user').value = data.database.user || ''; + document.getElementById('cfg-db-password').value = data.database.password || ''; + if (data.database.sslMode) { + document.getElementById('cfg-db-ssl').value = data.database.sslMode; + } + } + + // Deployment Center + if (data.deploymentCenter) { + document.getElementById('cfg-dc-url').value = data.deploymentCenter.serverUrl || ''; + document.getElementById('cfg-dc-token').value = data.deploymentCenter.token || ''; + document.getElementById('cfg-dc-heartbeat').checked = !!data.deploymentCenter.heartbeatEnabled; + document.getElementById('cfg-dc-source').value = data.deploymentCenter.source || ''; + document.getElementById('cfg-dc-instance').value = data.deploymentCenter.instance || ''; + document.getElementById('cfg-dc-interval').value = data.deploymentCenter.intervalSeconds || 60; + document.getElementById('cfg-dc-errorreporting').checked = !!data.deploymentCenter.errorReportingEnabled; + document.getElementById('cfg-dc-updatecheck').checked = !!data.deploymentCenter.updateCheckEnabled; + if (data.deploymentCenter.updateChannel) { + document.getElementById('cfg-dc-channel').value = data.deploymentCenter.updateChannel; + } + } + + // Egress + if (data.egress) { + document.getElementById('cfg-egress-channels').value = data.egress.channelsText || ''; + } + + } catch (e) { + console.warn('Failed to load settings:', e); + } +} + +async function saveSettings() { + const payload = { + adminServer: { + port: parseInt(document.getElementById('cfg-admin-port').value, 10), + host: document.getElementById('cfg-admin-host').value.trim(), + token: document.getElementById('cfg-admin-token').value.trim() + }, + webserver: { + port: parseInt(document.getElementById('cfg-web-port').value, 10), + host: document.getElementById('cfg-web-host').value.trim(), + apiControlToken: document.getElementById('cfg-web-token').value.trim(), + dbConnectionDebug: document.getElementById('cfg-web-dbdebug').checked + }, + database: { + server: document.getElementById('cfg-db-server').value.trim(), + database: document.getElementById('cfg-db-name').value.trim(), + user: document.getElementById('cfg-db-user').value.trim(), + password: document.getElementById('cfg-db-password').value.trim(), + sslMode: document.getElementById('cfg-db-ssl').value + }, + deploymentCenter: { + token: document.getElementById('cfg-dc-token').value.trim(), + heartbeatEnabled: document.getElementById('cfg-dc-heartbeat').checked, + source: document.getElementById('cfg-dc-source').value.trim(), + instance: document.getElementById('cfg-dc-instance').value.trim(), + intervalSeconds: parseInt(document.getElementById('cfg-dc-interval').value, 10), + errorReportingEnabled: document.getElementById('cfg-dc-errorreporting').checked, + updateCheckEnabled: document.getElementById('cfg-dc-updatecheck').checked, + updateChannel: document.getElementById('cfg-dc-channel').value + }, + egress: { + channelsText: document.getElementById('cfg-egress-channels').value + } + }; + + try { + const res = await api('/api/admin/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await res.json(); + if (res.ok) { + showToast(data.message || 'Einstellungen gespeichert.', 'success'); + // Wenn der Admin-Token geändert wurde, Session aktualisieren + if (payload.adminServer.token && payload.adminServer.token !== currentToken) { + currentToken = payload.adminServer.token; + sessionStorage.setItem(STORAGE_KEY, currentToken); + } + } else { + showToast(data.error || 'Fehler beim Speichern der Einstellungen.', 'error'); + } + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +async function generateNewAdminToken() { + try { + const res = await api('/api/admin/settings/generate-token', { method: 'POST' }); + if (res.ok) { + const data = await res.json(); + document.getElementById('cfg-admin-token').value = data.token; + showToast('Neuer sicherer Token generiert. Zum Übernehmen auf "Speichern" klicken.', 'warning'); + } + } catch (e) { + showToast('Token-Generierung fehlgeschlagen.', 'error'); + } +} + +// ─── LIZENZ & UPDATES ─── + +async function loadLicenseInfo() { + try { + const res = await api('/api/admin/license'); + if (!res.ok) return; + const data = await res.json(); + + document.getElementById('lic-product').textContent = data.productSlug; + document.getElementById('lic-version').textContent = `v${data.version} (${data.gitCommit || 'head'})`; + document.getElementById('lic-hwid').textContent = data.hardwareId || '—'; + document.getElementById('lic-hwidsource').textContent = 'Quelle: ' + (data.hwidSource || '—'); + document.getElementById('lic-status').textContent = data.status; + document.getElementById('lic-status').style.color = data.isUsable ? '#10b981' : '#ef4444'; + document.getElementById('lic-message').textContent = data.message || 'Gültig'; + + } catch (e) { + console.warn('Failed to load license info:', e); + } +} + +async function checkForUpdates() { + showToast('Prüfe auf Updates...', 'info'); + try { + const res = await api('/api/admin/updates/check', { method: 'POST' }); + if (!res.ok) throw new Error('Prüfung fehlgeschlagen'); + const data = await res.json(); + + const banner = document.getElementById('update-banner'); + const title = document.getElementById('update-banner-title'); + const body = document.getElementById('update-banner-body'); + + if (data.updateAvailable) { + banner.style.display = 'block'; + title.textContent = `⬆️ Neues Release v${data.latestVersion} verfügbar! (Installiert: v${data.currentVersion})`; + let html = ''; + if (data.isCritical) { + html += '
⚠️ WICHTIG: Dieses Update ist als kritisch markiert.
'; + } + if (data.changelog) { + html += `
${escapeHtml(data.changelog)}
`; + } + body.innerHTML = html; + showToast(`Update verfügbar: v${data.latestVersion}`, 'warning'); + } else { + banner.style.display = 'none'; + showToast(data.message || `Predictalytics v${data.currentVersion} ist aktuell.`, 'success'); + } + } catch (e) { + showToast('Update-Prüfung fehlgeschlagen: ' + e.message, 'error'); + } +} + +// ─── NAVIGATION & TABS ─── + +function setupNavigation() { + const links = document.querySelectorAll('.nav-link'); + links.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const tabName = link.getAttribute('data-tab'); + switchTab(tabName); + }); + }); +} + +function switchTab(tabName) { + document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); + + const targetLink = document.querySelector(`.nav-link[data-tab="${tabName}"]`); + const targetPanel = document.getElementById('panel-' + tabName); + + if (targetLink) targetLink.classList.add('active'); + if (targetPanel) targetPanel.classList.add('active'); + + // Wenn spezifische Tabs aktiviert werden, Daten nachladen + if (tabName === 'logfiles') loadLogFiles(); + if (tabName === 'settings') loadSettings(); + if (tabName === 'license') loadLicenseInfo(); +} + +// ─── TOAST BENACHRICHTIGUNGEN ─── + +function showToast(message, type = 'info') { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + container.appendChild(toast); + + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateY(10px)'; + toast.style.transition = 'all 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, 4000); +} + +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/src/Predictalytics.Hosting/LoggingSetup.cs b/src/Predictalytics.Hosting/LoggingSetup.cs index 512efa8..a90c55c 100644 --- a/src/Predictalytics.Hosting/LoggingSetup.cs +++ b/src/Predictalytics.Hosting/LoggingSetup.cs @@ -79,6 +79,9 @@ public static class LoggingSetup config = config.WriteTo.Sink(new DelegateSink(terminalSink), restrictedToMinimumLevel: LogEventLevel.Warning); } + // ── In-Memory Ringpuffer für Admin WebGUI Live-Terminal ── + config = config.WriteTo.Sink(new Predictalytics.Hosting.Admin.InMemoryLogBufferSink(), restrictedToMinimumLevel: LogEventLevel.Information); + config = config // ══════════════════════════════════════════════ // FILE SINKS — By Level diff --git a/src/Predictalytics.Hosting/PredictalyticsHost.cs b/src/Predictalytics.Hosting/PredictalyticsHost.cs index a9765cf..bf85549 100644 --- a/src/Predictalytics.Hosting/PredictalyticsHost.cs +++ b/src/Predictalytics.Hosting/PredictalyticsHost.cs @@ -26,6 +26,9 @@ public sealed class PredictalyticsHost private WebApplication? _app; private Task? _runTask; private CancellationTokenSource? _cts; + private Predictalytics.Hosting.Admin.AdminWebServer? _adminServer; + private CancellationTokenSource? _workerCts; + private Task? _workerRunTask; private readonly object _lock = new(); public PredictalyticsHost(PredictalyticsOptions options) => Options = options; @@ -38,8 +41,45 @@ public sealed class PredictalyticsHost public bool WebServerRunning { get; private set; } public bool WorkersRunning { get; private set; } + public bool AdminServerRunning => _adminServer?.IsRunning ?? false; - private void RaiseStateChanged() => StateChanged?.Invoke(); + public bool IsBusy { get; private set; } + public string? BusyOperation { get; private set; } + + public void RaiseStateChanged() => StateChanged?.Invoke(); + + /// + /// Führt eine Wartungsaktion geschützt aus, sofern keine Hintergrund-Worker aktiv sind. + /// Aktualisiert den IsBusy-Zustand für WebGUI und Desktop-GUI. + /// + public async Task RunGuardedMaintenanceAsync(string operationName, Func> action, CancellationToken ct = default) + { + if (WorkersRunning) + throw new InvalidOperationException($"'{operationName}' kann nicht ausgeführt werden, während die Hintergrund-Worker aktiv sind."); + + lock (_lock) + { + if (IsBusy) + throw new InvalidOperationException($"Es läuft bereits eine Operation: '{BusyOperation}'."); + IsBusy = true; + BusyOperation = operationName; + RaiseStateChanged(); + } + + try + { + return await action(ct); + } + finally + { + lock (_lock) + { + IsBusy = false; + BusyOperation = null; + RaiseStateChanged(); + } + } + } // ───────────────────────────────────────────────────────────────────────── // Wartungsaktionen @@ -280,10 +320,93 @@ public sealed class PredictalyticsHost RaiseStateChanged(); } + // ───────────────────────────────────────────────────────────────────────── + // Admin-Webserver (Verwaltungs-WebGUI) + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Startet den separaten Kestrel-Webserver für das Verwaltungs-WebGUI (Port 5001). + /// + public async Task StartAdminServerAsync() + { + lock (_lock) + { + if (_adminServer != null && _adminServer.IsRunning) return; + _adminServer ??= new Predictalytics.Hosting.Admin.AdminWebServer(this); + } + + try + { + await _adminServer.StartAsync(); + RaiseStateChanged(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to start Admin Kestrel webserver"); + RaiseStateChanged(); + throw; + } + } + + /// + /// Stoppt den separaten Kestrel-Webserver des Verwaltungs-WebGUIs. + /// + public async Task StopAdminServerAsync() + { + Predictalytics.Hosting.Admin.AdminWebServer? server; + lock (_lock) + { + server = _adminServer; + } + + if (server != null) + { + await server.StopAsync(); + RaiseStateChanged(); + } + } + // ───────────────────────────────────────────────────────────────────────── // Worker // ───────────────────────────────────────────────────────────────────────── + /// + /// Startet die Hintergrund-Worker asynchron im Hintergrund (wird von Shell und WebGUI gerufen). + /// + public void StartWorkers() + { + lock (_lock) + { + if (WorkersRunning) return; + _workerCts = new CancellationTokenSource(); + var ct = _workerCts.Token; + + _workerRunTask = Task.Run(async () => + { + try + { + await StartWorkersAsync(ct); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Log.Error(ex, "Fehler während der Ausführung der Hintergrund-Worker"); + } + }, ct); + } + } + + /// + /// Stoppt die Hintergrund-Worker geordnet über das CancellationToken. + /// + public void StopWorkers() + { + lock (_lock) + { + _workerCts?.Cancel(); + } + } + /// /// Startet die Hintergrund-Worker und laeuft, bis ausgeloest wird. /// diff --git a/src/Predictalytics.Hosting/PredictalyticsOptions.cs b/src/Predictalytics.Hosting/PredictalyticsOptions.cs index 4b502f7..a9fbb7d 100644 --- a/src/Predictalytics.Hosting/PredictalyticsOptions.cs +++ b/src/Predictalytics.Hosting/PredictalyticsOptions.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; namespace Predictalytics.Hosting; @@ -63,6 +64,60 @@ public sealed class PredictalyticsOptions } } + // ─── Verwaltungs-Webserver (Admin WebGUI) ─── + + [Category("Verwaltungs-Webserver")] + [DisplayName("Admin Webserver aktiv")] + [Description("Aktiviert das separate Verwaltungs-WebGUI zur Steuerung und Wartung.")] + [DefaultValue(true)] + public bool AdminServerEnabled { get; set; } = true; + + [Category("Verwaltungs-Webserver")] + [DisplayName("Port")] + [Description("Der Port, über den das Verwaltungs-WebGUI erreichbar ist.")] + [DefaultValue(5001)] + public int AdminServerPort { get; set; } = 5001; + + [Category("Verwaltungs-Webserver")] + [DisplayName("Bind-Adresse")] + [Description("Netzwerkschnittstelle, auf der das Verwaltungs-WebGUI lauscht. 'localhost' = nur lokal (Standard), '0.0.0.0' = netzwerkweit erreichbar.")] + [DefaultValue("localhost")] + public string AdminServerHost { get; set; } = "localhost"; + + [Category("Verwaltungs-Webserver")] + [DisplayName("Admin-Token")] + [Description("Schützt das gesamte Verwaltungs-WebGUI. Ohne diesen Token ist kein Zugriff möglich. Kann neu generiert werden.")] + [PasswordPropertyText(true)] + public string AdminToken { get; set; } = ""; + + /// + /// Prüft, ob die Admin-Bind-Adresse öffentlich bzw. über das Netzwerk erreichbar ist. + /// + [Browsable(false)] + [JsonIgnore] + public bool IsAdminPubliclyBound + { + get + { + var host = (AdminServerHost ?? "").Trim(); + return host.Length > 0 + && !host.Equals("localhost", StringComparison.OrdinalIgnoreCase) + && host != "127.0.0.1" + && host != "::1"; + } + } + + /// Kestrel-Bind-URL für das Admin-WebGUI. + [Browsable(false)] + [JsonIgnore] + public string AdminServerUrl => + $"http://{(string.IsNullOrWhiteSpace(AdminServerHost) ? "localhost" : AdminServerHost.Trim())}:{AdminServerPort}"; + + /// Generiert einen sicheren, kryptografischen Zufallstoken für das Admin-WebGUI. + public static string GenerateSecureToken(int byteLength = 24) => + Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(byteLength)).ToLowerInvariant(); + + // ─── Egress ─── private string _egressChannelsText = ""; @@ -224,12 +279,40 @@ public sealed class PredictalyticsOptions { var options = JsonSerializer.Deserialize(File.ReadAllText(path)) ?? new PredictalyticsOptions(); - if (path == LegacySettingsFilePath) options.Save(); // Migration festschreiben + var modified = path == LegacySettingsFilePath; + if (string.IsNullOrWhiteSpace(options.AdminToken)) + { + options.AdminToken = GenerateSecureToken(); + modified = true; + } + if (modified) options.Save(); // Migration oder Initial-Token festschreiben return options; } catch { - return new PredictalyticsOptions(); + var fallback = new PredictalyticsOptions(); + if (string.IsNullOrWhiteSpace(fallback.AdminToken)) + { + fallback.AdminToken = GenerateSecureToken(); + try { fallback.Save(); } catch { } + } + return fallback; + } + } + + /// + /// Ermöglicht das Übersteuern von Einstellungen über appsettings.json oder Umgebungsvariablen. + /// + public void ApplyConfigurationOverrides(Microsoft.Extensions.Configuration.IConfiguration? configuration) + { + if (configuration == null) return; + var section = configuration.GetSection("Admin"); + if (section.Exists()) + { + if (bool.TryParse(section["Enabled"], out var enabled)) AdminServerEnabled = enabled; + if (int.TryParse(section["Port"], out var port) && port > 0) AdminServerPort = port; + if (!string.IsNullOrWhiteSpace(section["Host"])) AdminServerHost = section["Host"]!.Trim(); + if (!string.IsNullOrWhiteSpace(section["Token"])) AdminToken = section["Token"]!.Trim(); } } diff --git a/src/Predictalytics.Shell/Predictalytics.Shell.csproj b/src/Predictalytics.Shell/Predictalytics.Shell.csproj index c13bb04..e59ccf8 100644 --- a/src/Predictalytics.Shell/Predictalytics.Shell.csproj +++ b/src/Predictalytics.Shell/Predictalytics.Shell.csproj @@ -42,6 +42,10 @@ Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + diff --git a/src/Predictalytics.Shell/Services/HeadlessRunner.cs b/src/Predictalytics.Shell/Services/HeadlessRunner.cs index 7df2653..9fb39e6 100644 --- a/src/Predictalytics.Shell/Services/HeadlessRunner.cs +++ b/src/Predictalytics.Shell/Services/HeadlessRunner.cs @@ -53,6 +53,12 @@ internal static class HeadlessRunner heartbeat = StartHeartbeat(options, host); + if (options.AdminServerEnabled) + { + await host.StartAdminServerAsync(); + Log.Information("Verwaltungs-WebGUI erreichbar unter {Url}", options.AdminServerUrl); + } + await host.StartWebServerAsync(); Log.Information("WebUI erreichbar unter {Url}", options.WebserverUrl); @@ -61,6 +67,10 @@ internal static class HeadlessRunner heartbeat?.NotifyStopping(); await host.StopWebServerAsync(); + if (options.AdminServerEnabled) + { + await host.StopAdminServerAsync(); + } Log.Information("Dienst planmäßig beendet."); return ExitOk; } diff --git a/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs b/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs index 73918be..9654a34 100644 --- a/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs +++ b/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs @@ -18,7 +18,6 @@ public sealed partial class MainWindowViewModel : ObservableObject private const int MaxLogLines = 500; private readonly PredictalyticsHost _host; - private CancellationTokenSource? _workerCts; private DcHeartbeatService? _heartbeat; private double? _lastDbSizeMb; @@ -79,6 +78,22 @@ public sealed partial class MainWindowViewModel : ObservableObject RestartHeartbeat(); + if (Options.AdminServerEnabled) + { + _ = Task.Run(async () => + { + try + { + await _host.StartAdminServerAsync(); + Log.Information("Verwaltungs-WebGUI erreichbar unter {Url}", Options.AdminServerUrl); + } + catch (Exception ex) + { + Log.Error(ex, "Verwaltungs-WebGUI konnte nicht gestartet werden"); + } + }); + } + if (Options.DcUpdateCheckEnabled) { _ = CheckForUpdatesAsync(silent: true); @@ -292,23 +307,17 @@ public sealed partial class MainWindowViewModel : ObservableObject // ─── Befehle ─── [RelayCommand] - private async Task ToggleServerAsync() + private void ToggleServer() { 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"); } + _host.StartWorkers(); } else { Log.Information("⏹ Stoppe Hintergrund-Worker..."); - _workerCts?.Cancel(); + _host.StopWorkers(); Log.Information("Worker gestoppt."); } UpdateStatus(); @@ -354,6 +363,17 @@ public sealed partial class MainWindowViewModel : ObservableObject [RelayCommand] private void OpenWebUi() => OpenInShell(Options.WebserverUrl); + [RelayCommand] + private void OpenAdminWebUi() => OpenInShell(Options.AdminServerUrl); + + [RelayCommand] + private void GenerateAdminToken() + { + Options.AdminToken = PredictalyticsOptions.GenerateSecureToken(); + OnPropertyChanged(nameof(Options)); + Log.Information("Neuer Admin-Token generiert. Zum Übernehmen 'Speichern & übernehmen' klicken."); + } + [RelayCommand] private void OpenLogFolder() { @@ -476,7 +496,8 @@ public sealed partial class MainWindowViewModel : ObservableObject _heartbeat?.NotifyStopping(); _heartbeat?.Dispose(); _heartbeat = null; - _workerCts?.Cancel(); + _host.StopWorkers(); try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ } + try { _host.StopAdminServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ } } } diff --git a/src/Predictalytics.Shell/Views/MainWindow.axaml b/src/Predictalytics.Shell/Views/MainWindow.axaml index 81f362c..d2e2544 100644 --- a/src/Predictalytics.Shell/Views/MainWindow.axaml +++ b/src/Predictalytics.Shell/Views/MainWindow.axaml @@ -42,7 +42,8 @@ - + + @@ -135,6 +136,36 @@ + + + + + + + + + + + + + + + + + + + +