Author SHA1 Message Date
Richard 0fd44a3eb0 Standardports auf 4000 (Analyse Webserver) und 4001 (Admin Webserver) geändert 2026-09-08 12:38:02 +02:00
Richard 1131d257cf 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
2026-09-07 21:09:03 +02:00
RichardandClaude Opus 5 301249f3b8 Roadmap ins Deployment Center, Version auf 1.1.0
Die Roadmap wird ab jetzt im Deployment Center gepflegt, nicht mehr in der
Datei. Uebertragen sind acht Themenbereiche und 28 Eintraege - keine
Waisen, keiner ohne Dokumentbezug: 12 geplant, 4 erledigt, 8
zurueckgestellt, 4 verworfen.

Jeder zurueckgestellte Eintrag traegt den geforderten Ausloeser; die API
weist status=deferred ohne defer_trigger mit 400 ab. Ohne benannten Anlass
ist ein Vorhaben nicht zurueckgestellt, sondern vergessen.

In ROADMAP.md ersetzt der erzeugte dc:roadmap-Block die handgepflegte
Fortschrittstabelle - genau die ging als Erstes veraltet. Die Prosa
darunter bleibt unberuehrt und ist weiterhin die Begruendung. Erneuern
laesst sich der Block ueber action=render, action=check meldet, wenn er
veraltet ist.

Version 1.0.0 stand seit jeher fest. Dadurch trugen beide Releases, alle
offenen Items und alle Supervisor-Befunde dieselbe Fassung, und die
Detektoren regression.build und deviation.fleet konnten strukturell nie
anschlagen. Mit 1.1.0 gibt es zum ersten Mal zwei unterscheidbare Staende.

Bugtracker: #414

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 13:58:01 +02:00
RichardandClaude Opus 5 983900000a Programm-Icon an allen Stellen gesetzt
Das Projekt setzte nirgends ein Icon - weder <ApplicationIcon> noch
Window.Icon, und in der Web-UI kein Favicon. Auf dem Server trugen dadurch
alle unsere Anwendungen dasselbe generische Platzhalter-Icon und waren in
Explorer und Taskleiste nicht auseinanderzuhalten.

Assets/predictalytics.ico ist die einzige Quelle, alles andere daraus
abgeleitet (Ableitung und Neuerzeugung: Assets/README.md):

* <ApplicationIcon> im Shell-Projekt - Explorer, Taskleiste, Task-Manager
* Window.Icon in MainWindow und LicenseWindow; die im Code gebauten
  Dialoge erben es vom Besitzerfenster
* favicon.ico, apple-touch-icon.png und icon-192/512.png in der Web-UI,
  verlinkt aus index.html, landing.html und docs.html

Fuer das Fenstericon bewusst PNG statt ICO: Skia dekodiert kein ICO, unter
Linux waere der Fensteraufbau sonst gescheitert. Die .ico bleibt per
Exclude aus dem AvaloniaResource-Glob - sie geht ueber <ApplicationIcon>
in die Programmdatei und muss nicht zusaetzlich in die Assembly.

Der XAML-Compiler prueft Asset-URIs nicht (ein falscher Pfad baut
fehlerfrei durch), deshalb gegen die gebaute Assembly verifiziert: beide
relativen URIs loesen auf, WindowIcon dekodiert sie, der Gegentest mit
einem nicht vorhandenen Pfad schlaegt fehl.

Bugtracker: #418

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 13:57:48 +02:00
34 changed files with 3712 additions and 33 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<!-- Version der Anwendung. Wird von Deploymentcenter.BuildInfo.targets,
dem UpdateService, Fehler-Stream und Heartbeat genutzt. -->
<Version>1.0.0</Version>
<Version>1.1.0</Version>
<!-- Zielframework zentral fuer alle Projekte. Seit die Avalonia-Shell den
WinForms-Host abgeloest hat, gibt es keine windows-spezifische Ausnahme mehr. -->
<TargetFramework>net10.0</TargetFramework>
+88 -9
View File
@@ -12,15 +12,94 @@
## Fortschritt
| Stufe | Stand |
|---|---|
| 0 — Sofort | ✅ erledigt (2026-08-24) |
| 1 — Aufräumen abschließen | ✅ erledigt (2026-08-24) |
| 2 — Portierung abschließen | offen — **braucht ein Linux-System**, sonst nicht prüfbar |
| 3 — Analytik schärfen | offen, 3.1 ist der nächste inhaltliche Schritt |
| 4 — Datenhaushalt | offen, liegt beim Nutzer (SQL) |
| 5 — Ingest skalieren | offen, erst bei Bedarf |
| 6 — Monetarisierung | Konzept |
> Stand und Reihenfolge werden im **Deployment Center** gepflegt, nicht mehr hier:
> `GET /api/bugtracker/v1/roadmap?action=list&project_slug=predictalytics`.
> Der folgende Block ist daraus erzeugt (`action=render`); `action=check` meldet, wenn er
> veraltet ist. Die Abschnitte darunter bleiben die Begründung und werden von Hand gepflegt.
<!-- dc:roadmap stand=2026-09-01T08:36Z items=28 projekt=predictalytics -->
<!-- ERZEUGT - nicht von Hand aendern. Dieser Block wird bei jedem Lauf von
`php cli/roadmap.php --write` ueberschrieben. Der Zustand wird im
Deploymentcenter gepflegt, nicht hier. Prosa gehoert AUSSERHALB der
Marker - dort bleibt sie unberuehrt. -->
### Als Naechstes
1. **[2] #429 — [2.1] Erstlauf und Verifikation unter Linux**
2. **[3] #431 — [3.1] Strategie-Klassifikation reparieren (ehemals A4)**
3. **[4] #438 — [4.1] Einmalige DB-Optimierungen (liegt beim Nutzer)**
4. **[2] #430 — [2.2] Deployment und CI**
### 0 — Stufe 0 - Sofort
| | Vorhaben | Stand | Ziel |
|---|---|---|---|
| #425 | [0.1] OpenRouter-API-Key aus dem Repository entfernt | ✅ Abgeschlossen | — |
### 1 — Stufe 1 - Aufraeumen abschliessen
| | Vorhaben | Stand | Ziel |
|---|---|---|---|
| #426 | [1.1] Nach main gebracht, zehn Feature-Zweige entfernt | ✅ Abgeschlossen | — |
| #427 | [1.2] Nullable-Warnungen behoben - der Build ist warnungsfrei | ✅ Abgeschlossen | — |
| #428 | [1.3] Kategorie-Backfill laedt nicht mehr den ganzen Marktbestand | ✅ Abgeschlossen | — |
### 2 — Stufe 2 - Portierung abschliessen
| | Vorhaben | Stand | Ziel |
|---|---|---|---|
| #429 | [2.1] Erstlauf und Verifikation unter Linux | 📋 Geplant | — |
| #430 | [2.2] Deployment und CI | 📋 Geplant | — |
### 3 — Stufe 3 - Analytik schaerfen
| | Vorhaben | Stand | Ziel |
|---|---|---|---|
| #431 | [3.1] Strategie-Klassifikation reparieren (ehemals A4) | 📋 Geplant | — |
| #432 | [3.2] CopytradingBacktestHarness anschliessen oder verwerfen | 📋 Geplant | — |
| #433 | [3.3] Track-Record-Laenge in den Copyability-Score | 📋 Geplant | — |
| #434 | [3.4] Edge-Freshness und Strategie-Drift ins Ranking | 📋 Geplant | — |
| #435 | [3.5] Co-Movement-Graph vorberechnen | 📋 Geplant | — |
| #436 | [3.6] Restpunkte aus der Kategorie-Erkennung | 📋 Geplant | — |
| #437 | [3.7] Sortierpfeile in der Trader-Tabelle | 📋 Geplant | — |
### 4 — Stufe 4 - Datenhaushalt
| | Vorhaben | Stand | Ziel |
|---|---|---|---|
| #438 | [4.1] Einmalige DB-Optimierungen (liegt beim Nutzer) | 📋 Geplant | — |
| #439 | [4.2] Materialitaetsschwelle fuer Mikro-Trades (liegt beim Nutzer) | 🧊 Zurueckgestellt — Ausloeser: Wenn 4.1 umgesetzt und gemessen ist und beziffert werden kann, was die Schwelle zusaetzlich einbringt. | — |
| #440 | [4.3] Frischer Datenbank-Neustart (liegt beim Nutzer) | 🧊 Zurueckgestellt — Ausloeser: Wenn Stufe 3 abgeschlossen ist und der vorgelagerte Stichproben-Check bestaetigt hat, wie weit /activity fuer ein lan... | — |
| #441 | [4.4] 65 Migrationsdateien zusammenfassen | 🧊 Zurueckgestellt — Ausloeser: Nur im Zuge von 4.3 - ohne den Datenbank-Neustart nicht angehen. | — |
### 5 — Stufe 5 - Ingest skalieren
| | Vorhaben | Stand | Ziel |
|---|---|---|---|
| #442 | [5.1] Historische Massendaten aus der Blockchain statt ueber REST | 🧊 Zurueckgestellt — Ausloeser: Wenn Rate-Limits im Ingest tatsaechlich druecken und die Evaluierung ergeben hat, dass ein hinreichend vollstaendiger... | — |
| #443 | [5.2] Markt-Stammdaten aggressiv cachen | 📋 Geplant | — |
| #444 | [5.3] Egress-Kanaele: Betriebsreste | 📋 Geplant | — |
| #445 | [5.4] Header- und User-Agent-Rotation - bewusst verworfen | ❌ Verworfen | — |
### 6 — Stufe 6 - Monetarisierung vorbereiten
| | Vorhaben | Stand | Ziel |
|---|---|---|---|
| #446 | [6.1] Datenbank fuer eine oeffentliche Schicht vorbereiten | 🧊 Zurueckgestellt — Ausloeser: Wenn die Entscheidung gefallen ist, aus dem internen Werkzeug ein Produkt zu machen - oder frueher, sobald die Pruefu... | — |
| #447 | [6.2] Oeffentlicher Host | 🧊 Zurueckgestellt — Ausloeser: Wenn die Entscheidung gefallen ist, aus dem internen Werkzeug ein Produkt zu machen. | — |
### A — Anhang - bewusst ausgelassen
| | Vorhaben | Stand | Ziel |
|---|---|---|---|
| #448 | [A.1] AzuroProvider loeschen - bewusst ausgelassen | ❌ Verworfen | — |
| #449 | [A.2] Providers/Limitless/ loeschen - bewusst ausgelassen | ❌ Verworfen | — |
| #450 | [A.3] wwwroot/landing.html und docs.html | 🧊 Zurueckgestellt — Ausloeser: Gehoeren zu Stufe 6 - erst mit der Entscheidung fuer einen oeffentlichen Host. | — |
| #451 | [A.4] Kaltarchiv ausserhalb der DB - verworfen | ❌ Verworfen | — |
| #452 | [A.5] EF Core auf 10 heben - blockiert | 🧊 Zurueckgestellt — Ausloeser: Wenn Pomelo einen EF-Core-10-Provider veroeffentlicht - vor einem Versuch dort nachsehen. | — |
<!-- /dc:roadmap -->
## Legende
+1 -1
View File
@@ -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
+7 -1
View File
@@ -18,7 +18,13 @@
"ApiSettings": {
"CanControl": true,
"AuthRequired": false,
"AllowedOrigins": [ "http://localhost:5000" ],
"AllowedOrigins": [ "http://localhost:4000" ],
"ReadOnlyDatabase": false
},
"Admin": {
"Enabled": true,
"Port": 4001,
"Host": "localhost",
"Token": ""
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+4 -1
View File
@@ -4,6 +4,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Predictalytics API Reference</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/icon-192.png" sizes="192x192">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
@@ -57,7 +60,7 @@
<h2 style="font-size:19px; font-weight:800; margin:0 0 12px;">Authentication</h2>
<p style="color:#8B93A7; font-size:13.5px; line-height:1.7; margin:0 0 14px;">Übergebe deinen API-Key (sofern aktiv) als Bearer-Token in jedem Request-Header.</p>
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:18px 20px; font-family:'Roboto Mono'; font-size:13px; color:#C7CCDA; overflow-x:auto;">
<pre>curl http://localhost:5000/api/dashboard \
<pre>curl http://localhost:4000/api/dashboard \
-H "Authorization: Bearer DEIN_API_KEY"</pre>
</div>
</section>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Predictalytics — Multi-Platform Prediction Market Smart-Money Tracker & Analytics">
<title>Predictalytics</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/icon-192.png" sizes="192x192">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="css/style.css?v=20260715">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
</head>
@@ -4,6 +4,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Predictalytics — See every trader and market before the crowd does</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/icon-192.png" sizes="192x192">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
@@ -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 = 4001,
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);
}
}
@@ -39,8 +39,8 @@ public class PredictalyticsOptionsTests
[Fact]
public void WebserverUrl_falls_back_to_localhost_when_host_is_blank()
{
var options = new PredictalyticsOptions { WebserverHost = " ", WebserverPort = 5000 };
Assert.Equal("http://localhost:5000", options.WebserverUrl);
var options = new PredictalyticsOptions { WebserverHost = " ", WebserverPort = 4000 };
Assert.Equal("http://localhost:4000", options.WebserverUrl);
}
[Fact]
@@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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: 4000</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 &amp; 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 &amp; höher</option>
<option value="WRN">Nur Warnungen &amp; 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- &amp; 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 &amp; 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 &amp; Ü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: 4001. 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 &amp; 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 &amp; 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 &amp; 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&#10;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 &amp; übernehmen</button>
</div>
</form>
</section>
<!-- 6. LIZENZ & UPDATES -->
<section id="panel-license" class="tab-panel">
<div class="panel-header">
<h2>Lizenzstatus &amp; 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 &amp; 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 || 4001;
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 || 4000;
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 4001).
/// </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;
@@ -21,8 +22,8 @@ public sealed class PredictalyticsOptions
[Category("Webserver")]
[DisplayName("Port")]
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
[DefaultValue(5000)]
public int WebserverPort { get; set; } = 5000;
[DefaultValue(4000)]
public int WebserverPort { get; set; } = 4000;
[Category("Webserver")]
[DisplayName("Bind-Adresse")]
@@ -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(4001)]
public int AdminServerPort { get; set; } = 4001;
[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();
}
}
+26
View File
@@ -0,0 +1,26 @@
# Programm-Icon
`predictalytics.ico` ist die **einzige Quelle**. Alles andere ist daraus abgeleitet:
| Datei | Wozu |
|---|---|
| `predictalytics.ico` (9 Größen, 16–256) | `<ApplicationIcon>` in `Predictalytics.Shell.csproj` — Explorer, Taskleiste, Task-Manager unter Windows |
| `predictalytics.png` (256) | `Window.Icon` in `MainWindow.axaml` und `LicenseWindow.axaml`; Dialoge erben es vom Besitzerfenster. Bewusst PNG, weil Skia unter Linux kein ICO dekodiert |
| `Predictalytics.Api/wwwroot/favicon.ico` (16–48) | Browser-Tab der Web-UI |
| `Predictalytics.Api/wwwroot/apple-touch-icon.png` (180) | iOS-Homescreen |
| `Predictalytics.Api/wwwroot/icon-192.png`, `icon-512.png` | Verlinkt aus `index.html`, `landing.html`, `docs.html` |
Ersetzt du das Icon, tausche `predictalytics.ico` aus und erzeuge den Rest neu:
```bash
python - <<'PY'
from PIL import Image
src = 'src/Predictalytics.Shell/Assets/predictalytics.ico'
im = Image.open(src); im.size = (256, 256); im.load(); im = im.convert('RGBA')
im.save('src/Predictalytics.Shell/Assets/predictalytics.png', optimize=True)
im.save('src/Predictalytics.Api/wwwroot/favicon.ico',
sizes=[(16,16),(24,24),(32,32),(48,48)])
for n, name in [(180,'apple-touch-icon.png'), (192,'icon-192.png'), (512,'icon-512.png')]:
im.resize((n,n), Image.LANCZOS).save(f'src/Predictalytics.Api/wwwroot/{name}', optimize=True)
PY
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

@@ -9,8 +9,18 @@
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<!-- Icon der Programmdatei. Unter Windows das, was Explorer, Taskleiste und
Task-Manager anzeigen — ohne das traegt jede unserer Anwendungen auf dem
Server dasselbe generische Platzhalter-Icon. -->
<ApplicationIcon>Assets\predictalytics.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<!-- Fenstericon fuer Avalonia. Die .ico bleibt aussen vor: sie geht ueber
<ApplicationIcon> in die Programmdatei, und Skia dekodiert ohnehin nur PNG. -->
<AvaloniaResource Include="Assets\**" Exclude="Assets\predictalytics.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
@@ -32,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 */ }
}
}
+4 -1
View File
@@ -37,7 +37,10 @@ public static class Dialogs
MaxWidth = 640,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
CanResize = false,
ShowInTaskbar = false
ShowInTaskbar = false,
// Ohne das zeigt die Titelleiste des Dialogs das leere Standardsymbol
// statt des Programmicons.
Icon = owner.Icon
};
for (var i = 0; i < buttons.Length; i++)
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Predictalytics.Shell.Views.LicenseWindow"
Title="Predictalytics — Lizenzaktivierung"
Icon="/Assets/predictalytics.png"
Width="580" SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen">
@@ -7,6 +7,7 @@
x:Class="Predictalytics.Shell.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="Predictalytics Analytics — Backend Server"
Icon="/Assets/predictalytics.png"
Width="1400" Height="900"
MinWidth="900" MinHeight="600"
WindowStartupLocation="CenterScreen">
@@ -41,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}" />
@@ -134,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)