Eigenständiges token-geschütztes Verwaltungs-WebGUI (Admin WebGUI) hinzugefügt
- 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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -20,5 +20,11 @@
|
||||
"AuthRequired": false,
|
||||
"AllowedOrigins": [ "http://localhost:5000" ],
|
||||
"ReadOnlyDatabase": false
|
||||
},
|
||||
"Admin": {
|
||||
"Enabled": true,
|
||||
"Port": 5001,
|
||||
"Host": "localhost",
|
||||
"Token": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> 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<bool>(false);
|
||||
EndpointFilterDelegate next = _ =>
|
||||
{
|
||||
called.Value = true;
|
||||
return ValueTask.FromResult<object?>("ok");
|
||||
};
|
||||
|
||||
return (new DefaultEndpointFilterInvocationContext(httpContext), next, called);
|
||||
}
|
||||
}
|
||||
@@ -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<string, string?>
|
||||
{
|
||||
["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<AdminLogEntry>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<LogEventLevel>(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<AdminLogEntry>(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<object>());
|
||||
|
||||
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<MySqlConnector.MySqlSslMode>(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; }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Predictalytics.Hosting.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class AdminAuthFilter : IEndpointFilter
|
||||
{
|
||||
public const string HeaderName = "X-Admin-Token";
|
||||
private readonly Func<string> _tokenProvider;
|
||||
|
||||
public AdminAuthFilter(string token) => _tokenProvider = () => token;
|
||||
public AdminAuthFilter(Func<string> tokenProvider) => _tokenProvider = tokenProvider;
|
||||
|
||||
public async ValueTask<object?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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(
|
||||
"<html><body style='font-family:sans-serif;background:#121218;color:#fff;padding:40px;text-align:center;'>" +
|
||||
"<h2>Predictalytics Admin Server</h2><p>Assets werden geladen oder admin_wwwroot fehlt.</p></body></html>",
|
||||
"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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Predictalytics.Hosting.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Repräsentiert einen einzelnen Log-Eintrag im Ringspeicher.
|
||||
/// </summary>
|
||||
public sealed record AdminLogEntry(
|
||||
long Id,
|
||||
DateTimeOffset Timestamp,
|
||||
LogEventLevel Level,
|
||||
string LevelText,
|
||||
string Message,
|
||||
string? Exception = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public sealed class InMemoryLogBuffer
|
||||
{
|
||||
private static readonly Lazy<InMemoryLogBuffer> _instance = new(() => new InMemoryLogBuffer());
|
||||
public static InMemoryLogBuffer Instance => _instance.Value;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly int _maxCapacity;
|
||||
private readonly LinkedList<AdminLogEntry> _entries = new();
|
||||
private long _nextId = 1;
|
||||
|
||||
public event Action<AdminLogEntry>? LogEmitted;
|
||||
|
||||
public InMemoryLogBuffer(int maxCapacity = 1000)
|
||||
{
|
||||
_maxCapacity = maxCapacity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fügt einen neuen Log-Eintrag hinzu und benachrichtigt verbundene Listener.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liefert die letzten N Einträge absteigend oder aufsteigend.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdminLogEntry> GetRecent(int count = 200, LogEventLevel? minLevel = null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
IEnumerable<AdminLogEntry> query = _entries;
|
||||
if (minLevel.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.Level >= minLevel.Value);
|
||||
}
|
||||
|
||||
return query.TakeLast(count).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liefert Einträge, die nach einer bestimmten ID eingetroffen sind (für Polling-Fallback).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdminLogEntry> GetSince(long afterId, int limit = 200, LogEventLevel? minLevel = null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
IEnumerable<AdminLogEntry> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serilog-Sink, der Ausgaben in den <see cref="InMemoryLogBuffer"/> schreibt.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Predictalytics Administration</title>
|
||||
<link rel="stylesheet" href="css/admin.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🛡️</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ─── TOKEN LOGIN GATEKEEPER ─── -->
|
||||
<div id="login-overlay" class="login-modal" style="display: flex;">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-shield">🛡️</div>
|
||||
<h1>Predictalytics</h1>
|
||||
<div class="login-badge">VERWALTUNG & ADMINISTRATION</div>
|
||||
<p class="login-hint">Dieses Interface ist passwortgeschützt. Bitte geben Sie den in den Einstellungen hinterlegten Admin-Token ein.</p>
|
||||
</div>
|
||||
|
||||
<form id="login-form" onsubmit="handleLogin(event)">
|
||||
<div class="form-group">
|
||||
<label for="admin-token-input">Admin-Token</label>
|
||||
<input type="password" id="admin-token-input" placeholder="Token eingeben..." required autocomplete="current-password" autofocus />
|
||||
</div>
|
||||
<div id="login-error" class="login-error" style="display: none;"></div>
|
||||
<button type="submit" id="login-btn" class="btn btn-primary btn-block">Entsperren & Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── HAUPT-DASHBOARD (initial ausgeblendet) ─── -->
|
||||
<div id="app-container" class="app-layout" style="display: none;">
|
||||
|
||||
<!-- TOPBAR -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="brand">
|
||||
<span class="brand-title">Predictalytics</span>
|
||||
<span class="badge badge-admin">ADMIN</span>
|
||||
</div>
|
||||
|
||||
<div class="status-indicators">
|
||||
<div id="status-workers-pill" class="status-pill status-stopped">
|
||||
<span class="status-dot"></span>
|
||||
<span id="status-workers-text">Workers: Gestoppt</span>
|
||||
</div>
|
||||
<div id="status-web-pill" class="status-pill status-stopped">
|
||||
<span class="status-dot"></span>
|
||||
<span id="status-web-text">Webserver: Gestoppt</span>
|
||||
</div>
|
||||
<div id="status-dbsize-pill" class="status-pill">
|
||||
<span>DB: <strong id="status-dbsize-text">—</strong></span>
|
||||
</div>
|
||||
<div id="busy-indicator" class="busy-pill" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<span id="busy-operation-text">Wird ausgeführt...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<button id="btn-toggle-workers" class="btn btn-sm btn-outline" onclick="toggleWorkers()">
|
||||
▶ Start Server
|
||||
</button>
|
||||
<button id="btn-toggle-webserver" class="btn btn-sm btn-outline" onclick="toggleWebserver()">
|
||||
▶ Start Webserver
|
||||
</button>
|
||||
<div class="topbar-divider"></div>
|
||||
<span id="header-version" class="version-tag">v—</span>
|
||||
<button class="btn btn-sm btn-secondary" onclick="handleLogout()" title="Sitzung beenden">
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- WORKSPACE (SIDEBAR + CONTENT) -->
|
||||
<div class="workspace">
|
||||
|
||||
<!-- SIDEBAR NAVIGATION -->
|
||||
<aside class="sidebar">
|
||||
<nav class="nav-menu">
|
||||
<a href="#dashboard" class="nav-link active" data-tab="dashboard">
|
||||
<span class="nav-icon">📊</span>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a href="#terminal" class="nav-link" data-tab="terminal">
|
||||
<span class="nav-icon">💻</span>
|
||||
<span>Terminal & Logs</span>
|
||||
</a>
|
||||
<a href="#logfiles" class="nav-link" data-tab="logfiles">
|
||||
<span class="nav-icon">📁</span>
|
||||
<span>Log-Dateien</span>
|
||||
</a>
|
||||
<a href="#maintenance" class="nav-link" data-tab="maintenance">
|
||||
<span class="nav-icon">🛠️</span>
|
||||
<span>Wartung</span>
|
||||
</a>
|
||||
<a href="#settings" class="nav-link" data-tab="settings">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span>Einstellungen</span>
|
||||
</a>
|
||||
<a href="#license" class="nav-link" data-tab="license">
|
||||
<span class="nav-icon">📜</span>
|
||||
<span>Lizenz & Updates</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="system-mini-info">
|
||||
<div>RAM: <strong id="sys-memory">—</strong></div>
|
||||
<div>Uptime: <strong id="sys-uptime">—</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- CONTENT PANELS -->
|
||||
<main class="content-area">
|
||||
|
||||
<!-- 1. DASHBOARD -->
|
||||
<section id="panel-dashboard" class="tab-panel active">
|
||||
<div class="panel-header">
|
||||
<h2>System-Übersicht</h2>
|
||||
<button class="btn btn-sm btn-secondary" onclick="refreshStatus()">Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">Hintergrund-Worker (Data Crawler & Scoring)</div>
|
||||
<div class="stat-value" id="dash-workers-state">Gestoppt</div>
|
||||
<p class="stat-desc">Polymarket, Limitless, Azuro und Analyse-Pipelines.</p>
|
||||
<div class="stat-actions">
|
||||
<button id="dash-btn-workers" class="btn btn-sm btn-primary" onclick="toggleWorkers()">Worker starten</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">Analyse-Webserver (Öffentliche WebUI)</div>
|
||||
<div class="stat-value" id="dash-web-state">Gestoppt</div>
|
||||
<p class="stat-desc" id="dash-web-url">Port: 5000</p>
|
||||
<div class="stat-actions">
|
||||
<button id="dash-btn-web" class="btn btn-sm btn-primary" onclick="toggleWebserver()">Webserver starten</button>
|
||||
<a id="dash-link-web" href="#" target="_blank" class="btn btn-sm btn-secondary" style="display:none;">WebUI im Browser öffnen ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">Datenbank-Speicher</div>
|
||||
<div class="stat-value" id="dash-db-size">— MB</div>
|
||||
<p class="stat-desc">MySQL / MariaDB Tabellen & Indizes</p>
|
||||
<div class="stat-actions">
|
||||
<button class="btn btn-sm btn-secondary" onclick="triggerDbUpdate()">Schema aktualisieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">System-Ressourcen</div>
|
||||
<div class="stat-value" id="dash-memory">— MB</div>
|
||||
<p class="stat-desc" id="dash-uptime">Uptime: —</p>
|
||||
<div class="stat-actions">
|
||||
<button class="btn btn-sm btn-secondary" onclick="switchTab('terminal')">Terminal ansehen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schnellaktionen -->
|
||||
<div class="section-box">
|
||||
<h3>Häufige Verwaltungsaktionen</h3>
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-secondary" onclick="triggerMarketSync()">🏛️ Märkte synchronisieren</button>
|
||||
<button class="btn btn-secondary" onclick="triggerDbUpdate()">🗄️ Datenbank aktualisieren</button>
|
||||
<button class="btn btn-secondary" onclick="checkForUpdates()">⬆️ Nach Updates suchen</button>
|
||||
<button class="btn btn-warning" onclick="openRecalculateModal()">🔄 Alle Trader neu berechnen…</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. TERMINAL & LOGS -->
|
||||
<section id="panel-terminal" class="tab-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Live-Terminal</h2>
|
||||
<div class="terminal-controls">
|
||||
<label class="toggle-control">
|
||||
<input type="checkbox" id="term-autoscroll" checked />
|
||||
<span>Autoscroll</span>
|
||||
</label>
|
||||
<select id="term-level-filter" class="form-select-sm" onchange="filterTerminalLogs()">
|
||||
<option value="ALL">Alle Stufen</option>
|
||||
<option value="INF">Info & höher</option>
|
||||
<option value="WRN">Nur Warnungen & Fehler</option>
|
||||
<option value="ERR">Nur Fehler</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-secondary" onclick="clearTerminal()">Terminal leeren</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="reconnectTerminalStream()">Neu verbinden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-wrapper">
|
||||
<div id="terminal-body" class="terminal-body">
|
||||
<div class="log-line log-inf"><span>Terminal initialisiert. Verbinde mit Live-Stream...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. LOG-DATEIEN -->
|
||||
<section id="panel-logfiles" class="tab-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Log-Dateien</h2>
|
||||
<button class="btn btn-sm btn-secondary" onclick="loadLogFiles()">Liste aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<p class="section-hint">Übersicht aller auf der Maschine abgelegten Log-Dateien nach Kategorien (all, info, workers, platforms). Jede Datei kann direkt heruntergeladen werden.</p>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kategorie</th>
|
||||
<th>Dateiname</th>
|
||||
<th class="num">Größe</th>
|
||||
<th>Geändert (UTC)</th>
|
||||
<th class="actions-col">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logfiles-table-body">
|
||||
<tr><td colspan="5" class="loading-cell">Lade Log-Dateien...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. WARTUNG -->
|
||||
<section id="panel-maintenance" class="tab-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Wartungs- & Entwicklungsaktionen</h2>
|
||||
</div>
|
||||
|
||||
<div class="maintenance-grid">
|
||||
|
||||
<div class="maintenance-card">
|
||||
<div class="card-icon">🏛️</div>
|
||||
<div class="card-content">
|
||||
<h3>Märkte synchronisieren</h3>
|
||||
<p>Führt einen vollständigen Einmal-Sync aller Märkte und Events für Polymarket, Limitless und Azuro durch. Aktualisiert Titel, Auflösungszustände und Quoten.</p>
|
||||
<div class="card-badge">Voraussetzung: Worker müssen gestoppt sein</div>
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<button class="btn btn-secondary" onclick="triggerMarketSync()">Markt-Sync starten</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="maintenance-card">
|
||||
<div class="card-icon">🗄️</div>
|
||||
<div class="card-content">
|
||||
<h3>Datenbank aktualisieren</h3>
|
||||
<p>Wendet ausstehende Entity Framework Core Migrationen auf die konfigurierte Datenbank an und stellt das Schema sicher.</p>
|
||||
<div class="card-badge">Voraussetzung: Worker müssen gestoppt sein</div>
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<button class="btn btn-secondary" onclick="triggerDbUpdate()">Datenbank aktualisieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="maintenance-card danger-border">
|
||||
<div class="card-icon">🔄</div>
|
||||
<div class="card-content">
|
||||
<h3>Alle Trader neu berechnen</h3>
|
||||
<p>Löscht alle <strong>abgeleiteten</strong> Analysedaten (Positionen, Tages-Snapshots, Kategorie-Statistiken) und reiht sämtliche Trader zur vollständigen Neuberechnung mit der aktuellen Engine ein. Rohdaten (Trades und Märkte) bleiben unberührt.</p>
|
||||
<div class="card-badge badge-warning">Achtung: Bereinigt Snapshots & Zwischenwerte</div>
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<button class="btn btn-danger" onclick="openRecalculateModal()">Neuberechnung ausführen…</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. EINSTELLUNGEN -->
|
||||
<section id="panel-settings" class="tab-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Einstellungen</h2>
|
||||
<button class="btn btn-primary" onclick="saveSettings()">Speichern & Übernehmen</button>
|
||||
</div>
|
||||
|
||||
<form id="settings-form" onsubmit="event.preventDefault(); saveSettings();">
|
||||
|
||||
<!-- VERWALTUNGS-WEBSERVER -->
|
||||
<div class="settings-group">
|
||||
<div class="group-header">Verwaltungs-Webserver (Dieses Admin-WebGUI)</div>
|
||||
<div class="form-row">
|
||||
<label>Admin-Port</label>
|
||||
<input type="number" id="cfg-admin-port" min="1" max="65535" required />
|
||||
<span class="field-hint">Standard: 5001. Bestimmt den Port, auf dem dieses Administrations-UI lauscht.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Admin Bind-Adresse</label>
|
||||
<input type="text" id="cfg-admin-host" required />
|
||||
<span class="field-hint">'localhost' = nur lokal auf dieser Maschine; '0.0.0.0' = netzwerkweit erreichbar.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Admin-Token</label>
|
||||
<div class="input-with-action">
|
||||
<input type="text" id="cfg-admin-token" required />
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="generateNewAdminToken()">Neu generieren</button>
|
||||
</div>
|
||||
<span class="field-hint">Schützt das gesamte Admin-WebGUI vor unbefugtem Zugriff. Bei Änderung muss neu angemeldet werden.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ANALYSE-WEBSERVER -->
|
||||
<div class="settings-group">
|
||||
<div class="group-header">Analyse-Webserver (Öffentliche API & Analyse-WebUI)</div>
|
||||
<div class="form-row">
|
||||
<label>Port</label>
|
||||
<input type="number" id="cfg-web-port" min="1" max="65535" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Bind-Adresse</label>
|
||||
<input type="text" id="cfg-web-host" required />
|
||||
<span class="field-hint">'localhost' = lokal; '0.0.0.0' = von außen erreichbar.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>API-Token (Steuerbefehle)</label>
|
||||
<input type="password" id="cfg-web-token" placeholder="••••••••" />
|
||||
<span class="field-hint">Schützt schreibende API-Befehle (X-Predictalytics-Key).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Datenbank-Debug</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="cfg-web-dbdebug" />
|
||||
<span>Detaillierte Verbindungs- und SQL-Infos loggen</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DATENBANK -->
|
||||
<div class="settings-group">
|
||||
<div class="group-header">Datenbank (MySQL / MariaDB)</div>
|
||||
<div class="form-row">
|
||||
<label>Server</label>
|
||||
<input type="text" id="cfg-db-server" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Datenbank</label>
|
||||
<input type="text" id="cfg-db-name" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Benutzer</label>
|
||||
<input type="text" id="cfg-db-user" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Passwort</label>
|
||||
<input type="password" id="cfg-db-password" placeholder="••••••••" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SSL-Modus</label>
|
||||
<select id="cfg-db-ssl">
|
||||
<option value="None">None (Unverschlüsselt)</option>
|
||||
<option value="Preferred">Preferred (Empfohlen für entfernte Server)</option>
|
||||
<option value="Required">Required (SSL erzwungen)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DEPLOYMENT CENTER -->
|
||||
<div class="settings-group">
|
||||
<div class="group-header">Deployment Center</div>
|
||||
<div class="form-row">
|
||||
<label>Server-URL</label>
|
||||
<input type="text" id="cfg-dc-url" readonly disabled class="input-disabled" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>API Token</label>
|
||||
<input type="password" id="cfg-dc-token" placeholder="••••••••" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Heartbeat aktiv</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="cfg-dc-heartbeat" />
|
||||
<span>Dead-Man's-Switch Pings an Watchdog senden</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Monitor Source & Instance</label>
|
||||
<div class="input-dual">
|
||||
<input type="text" id="cfg-dc-source" placeholder="Source (Predictalytics)" />
|
||||
<input type="text" id="cfg-dc-instance" placeholder="Instance (default)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Intervall (Sekunden)</label>
|
||||
<input type="number" id="cfg-dc-interval" min="15" max="3600" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Fehler melden</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="cfg-dc-errorreporting" />
|
||||
<span>Error/Fatal-Meldungen automatisch an das Deployment Center streamen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Update-Prüfung</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="cfg-dc-updatecheck" />
|
||||
<span>Beim Start auf neuere Versionen prüfen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Update-Kanal</label>
|
||||
<select id="cfg-dc-channel">
|
||||
<option value="prod">prod (Produktion)</option>
|
||||
<option value="beta">beta (Vorab-Versionen)</option>
|
||||
<option value="dev">dev (Entwicklung)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EGRESS -->
|
||||
<div class="settings-group">
|
||||
<div class="group-header">Egress (Proxies & Quell-IPs)</div>
|
||||
<div class="form-row">
|
||||
<label>Egress-Kanäle</label>
|
||||
<textarea id="cfg-egress-channels" rows="4" placeholder="prox-1|Proxy|http://user:pass@proxy:8080 ip-1|SourceIp|192.168.1.100"></textarea>
|
||||
<span class="field-hint">Format: id|type|value (Zeilengetrennt)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-submit-row">
|
||||
<button type="submit" class="btn btn-primary btn-large">Einstellungen speichern & übernehmen</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- 6. LIZENZ & UPDATES -->
|
||||
<section id="panel-license" class="tab-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Lizenzstatus & Deployment Center</h2>
|
||||
<button class="btn btn-sm btn-secondary" onclick="checkForUpdates()">Auf Updates prüfen</button>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">Produkt & Version</div>
|
||||
<div class="stat-value" id="lic-product">—</div>
|
||||
<p class="stat-desc" id="lic-version">Version: —</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">Hardware-Identifikation (HWID)</div>
|
||||
<div class="stat-value font-mono" id="lic-hwid" style="font-size: 14px;">—</div>
|
||||
<p class="stat-desc" id="lic-hwidsource">Quelle: —</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">Lizenzprüfung</div>
|
||||
<div class="stat-value" id="lic-status">—</div>
|
||||
<p class="stat-desc" id="lic-message">—</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="update-banner" class="section-box" style="display: none; margin-top: 24px;">
|
||||
<h3 id="update-banner-title">⬆️ Neues Update verfügbar</h3>
|
||||
<div id="update-banner-body"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ─── BESTÄTIGUNGS-MODAL FÜR NEUBERECHNUNG ─── -->
|
||||
<div id="confirm-modal" class="modal-backdrop" style="display: none;">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<span class="modal-icon">⚠️</span>
|
||||
<h3>Alle Trader neu berechnen</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Dies löscht alle <strong>abgeleiteten Analysedaten</strong> (Positionen, Tages-Snapshots, Kategorie-Statistiken) und markiert jeden Trader zur vollständigen Neuberechnung.</p>
|
||||
<p>Rohdaten (Trades und Märkte) bleiben unberührt.</p>
|
||||
<p>Danach muss der Server gestartet werden: Der Analytics-Worker baut jeden Trader mit der aktuellen Engine neu auf (kann je nach Datenbestand mehrere Stunden dauern).</p>
|
||||
<p><strong>Möchten Sie den Vorgang wirklich starten?</strong></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeRecalculateModal()">Abbrechen</button>
|
||||
<button class="btn btn-danger" onclick="executeRecalculateAll()">Ja, Daten zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOAST NOTIFICATION CONTAINER -->
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
|
||||
<script src="js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = '<div class="log-line log-inf"><span>Terminal geleert.</span></div>';
|
||||
}
|
||||
|
||||
// ─── LOG-DATEIEN ───
|
||||
|
||||
async function loadLogFiles() {
|
||||
const tbody = document.getElementById('logfiles-table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="loading-cell">Lade Log-Dateien...</td></tr>';
|
||||
|
||||
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 = '<tr><td colspan="5" class="loading-cell">Keine Log-Dateien gefunden.</td></tr>';
|
||||
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 = `
|
||||
<td><span class="badge" style="background:#1e293b;padding:2px 8px;border-radius:4px;">${escapeHtml(f.category)}</span></td>
|
||||
<td class="font-mono"><strong>${escapeHtml(f.name)}</strong></td>
|
||||
<td class="num">${f.sizeKb} KB</td>
|
||||
<td>${dateStr}</td>
|
||||
<td class="actions-col">
|
||||
<a href="${dlUrl}" class="btn btn-sm btn-secondary" download="${escapeHtml(f.name)}">⬇️ Herunterladen</a>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="loading-cell" style="color:#ef4444;">Log-Dateien konnten nicht geladen werden.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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 += '<div class="card-badge badge-warning" style="margin-bottom:8px;">⚠️ WICHTIG: Dieses Update ist als kritisch markiert.</div>';
|
||||
}
|
||||
if (data.changelog) {
|
||||
html += `<pre class="terminal-body" style="height:auto;max-height:200px;margin-top:8px;">${escapeHtml(data.changelog)}</pre>`;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
/// <summary>
|
||||
/// Führt eine Wartungsaktion geschützt aus, sofern keine Hintergrund-Worker aktiv sind.
|
||||
/// Aktualisiert den IsBusy-Zustand für WebGUI und Desktop-GUI.
|
||||
/// </summary>
|
||||
public async Task<T> RunGuardedMaintenanceAsync<T>(string operationName, Func<CancellationToken, Task<T>> 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)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Startet den separaten Kestrel-Webserver für das Verwaltungs-WebGUI (Port 5001).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stoppt den separaten Kestrel-Webserver des Verwaltungs-WebGUIs.
|
||||
/// </summary>
|
||||
public async Task StopAdminServerAsync()
|
||||
{
|
||||
Predictalytics.Hosting.Admin.AdminWebServer? server;
|
||||
lock (_lock)
|
||||
{
|
||||
server = _adminServer;
|
||||
}
|
||||
|
||||
if (server != null)
|
||||
{
|
||||
await server.StopAsync();
|
||||
RaiseStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Worker
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Startet die Hintergrund-Worker asynchron im Hintergrund (wird von Shell und WebGUI gerufen).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stoppt die Hintergrund-Worker geordnet über das CancellationToken.
|
||||
/// </summary>
|
||||
public void StopWorkers()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_workerCts?.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startet die Hintergrund-Worker und laeuft, bis <paramref name="ct"/> ausgeloest wird.
|
||||
/// </summary>
|
||||
|
||||
@@ -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; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob die Admin-Bind-Adresse öffentlich bzw. über das Netzwerk erreichbar ist.
|
||||
/// </summary>
|
||||
[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";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Kestrel-Bind-URL für das Admin-WebGUI.</summary>
|
||||
[Browsable(false)]
|
||||
[JsonIgnore]
|
||||
public string AdminServerUrl =>
|
||||
$"http://{(string.IsNullOrWhiteSpace(AdminServerHost) ? "localhost" : AdminServerHost.Trim())}:{AdminServerPort}";
|
||||
|
||||
/// <summary>Generiert einen sicheren, kryptografischen Zufallstoken für das Admin-WebGUI.</summary>
|
||||
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<PredictalyticsOptions>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ermöglicht das Übersteuern von Einstellungen über appsettings.json oder Umgebungsvariablen.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
|
||||
CopyToOutputDirectory="PreserveNewest"
|
||||
CopyToPublishDirectory="PreserveNewest" />
|
||||
<Content Include="..\Predictalytics.Hosting\Admin\wwwroot\**"
|
||||
Link="admin_wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
|
||||
CopyToOutputDirectory="PreserveNewest"
|
||||
CopyToPublishDirectory="PreserveNewest" />
|
||||
<None Include="run.sh" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Ansicht">
|
||||
<MenuItem Header="Log-Ordner öffnen" Command="{Binding OpenLogFolderCommand}" />
|
||||
<MenuItem Header="WebUI öffnen" Command="{Binding OpenWebUiCommand}" />
|
||||
<MenuItem Header="WebUI öffnen (Analyse)" Command="{Binding OpenWebUiCommand}" />
|
||||
<MenuItem Header="Verwaltungs-WebUI öffnen (Admin)" Command="{Binding OpenAdminWebUiCommand}" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Entwicklung">
|
||||
<MenuItem Header="Märkte synchronisieren" Command="{Binding SyncMarketsCommand}" />
|
||||
@@ -135,6 +136,36 @@
|
||||
</StackPanel>
|
||||
</HeaderedContentControl>
|
||||
|
||||
<!-- Verwaltungs-Webserver (Admin WebGUI) -->
|
||||
<HeaderedContentControl Classes="group" Header="Verwaltungs-Webserver (Admin WebGUI)">
|
||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||
<Grid ColumnDefinitions="200,*">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="Port" />
|
||||
<NumericUpDown Grid.Column="1" Value="{Binding AdminServerPort}"
|
||||
Minimum="1" Maximum="65535" FormatString="0" HorizontalAlignment="Left" Width="160" />
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="200,*">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="Bind-Adresse" />
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBox Text="{Binding AdminServerHost}" Width="260" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="'localhost' = nur lokal erreichbar. '0.0.0.0' = netzwerkweit erreichbar." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="200,*">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="Admin-Token" />
|
||||
<StackPanel Grid.Column="1">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding AdminToken}" Name="AdminTokenTextBox" />
|
||||
<Button Grid.Column="1" Margin="8,0,0,0" Content="Neu generieren" Click="OnGenerateAdminTokenClick" />
|
||||
</Grid>
|
||||
<TextBlock Classes="hint"
|
||||
Text="Schützt das gesamte Verwaltungs-WebGUI. Ohne diesen Token ist kein Zugriff möglich." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</HeaderedContentControl>
|
||||
|
||||
<!-- Datenbank -->
|
||||
<HeaderedContentControl Classes="group" Header="Datenbank">
|
||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||
|
||||
@@ -43,6 +43,16 @@ public partial class MainWindow : Window
|
||||
|
||||
private void OnExitClick(object? sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private void OnGenerateAdminTokenClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_vm?.GenerateAdminTokenCommand.Execute(null);
|
||||
var tb = this.FindControl<TextBox>("AdminTokenTextBox");
|
||||
if (tb != null && _vm != null)
|
||||
{
|
||||
tb.Text = _vm.Options.AdminToken;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
if (_vm != null)
|
||||
|
||||
Reference in New Issue
Block a user