Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fd44a3eb0 | ||
|
|
1131d257cf | ||
|
|
301249f3b8 | ||
|
|
983900000a |
@@ -2,7 +2,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<!-- Version der Anwendung. Wird von Deploymentcenter.BuildInfo.targets,
|
<!-- Version der Anwendung. Wird von Deploymentcenter.BuildInfo.targets,
|
||||||
dem UpdateService, Fehler-Stream und Heartbeat genutzt. -->
|
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
|
<!-- Zielframework zentral fuer alle Projekte. Seit die Avalonia-Shell den
|
||||||
WinForms-Host abgeloest hat, gibt es keine windows-spezifische Ausnahme mehr. -->
|
WinForms-Host abgeloest hat, gibt es keine windows-spezifische Ausnahme mehr. -->
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
|||||||
+88
-9
@@ -12,15 +12,94 @@
|
|||||||
|
|
||||||
## Fortschritt
|
## Fortschritt
|
||||||
|
|
||||||
| Stufe | Stand |
|
> Stand und Reihenfolge werden im **Deployment Center** gepflegt, nicht mehr hier:
|
||||||
|---|---|
|
> `GET /api/bugtracker/v1/roadmap?action=list&project_slug=predictalytics`.
|
||||||
| 0 — Sofort | ✅ erledigt (2026-08-24) |
|
> Der folgende Block ist daraus erzeugt (`action=render`); `action=check` meldet, wenn er
|
||||||
| 1 — Aufräumen abschließen | ✅ erledigt (2026-08-24) |
|
> veraltet ist. Die Abschnitte darunter bleiben die Begründung und werden von Hand gepflegt.
|
||||||
| 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 |
|
<!-- dc:roadmap stand=2026-09-01T08:36Z items=28 projekt=predictalytics -->
|
||||||
| 4 — Datenhaushalt | offen, liegt beim Nutzer (SQL) |
|
|
||||||
| 5 — Ingest skalieren | offen, erst bei Bedarf |
|
<!-- ERZEUGT - nicht von Hand aendern. Dieser Block wird bei jedem Lauf von
|
||||||
| 6 — Monetarisierung | Konzept |
|
`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
|
## Legende
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| Zielframework | net10.0, ein einziges für alle Projekte |
|
||||||
| Oberfläche | Avalonia 12.1.1 (Windows + Linux), zusätzlich ein Headless-Modus |
|
| 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 |
|
| 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** |
|
| Build | fehlerfrei und **warnungsfrei** |
|
||||||
|
|
||||||
### Aufbau
|
### Aufbau
|
||||||
|
|||||||
@@ -18,7 +18,13 @@
|
|||||||
"ApiSettings": {
|
"ApiSettings": {
|
||||||
"CanControl": true,
|
"CanControl": true,
|
||||||
"AuthRequired": false,
|
"AuthRequired": false,
|
||||||
"AllowedOrigins": [ "http://localhost:5000" ],
|
"AllowedOrigins": [ "http://localhost:4000" ],
|
||||||
"ReadOnlyDatabase": false
|
"ReadOnlyDatabase": false
|
||||||
|
},
|
||||||
|
"Admin": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Port": 4001,
|
||||||
|
"Host": "localhost",
|
||||||
|
"Token": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Predictalytics API Reference</title>
|
<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 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">
|
<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>
|
<style>
|
||||||
@@ -57,7 +60,7 @@
|
|||||||
<h2 style="font-size:19px; font-weight:800; margin:0 0 12px;">Authentication</h2>
|
<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>
|
<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;">
|
<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>
|
-H "Authorization: Bearer DEIN_API_KEY"</pre>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="description" content="Predictalytics — Multi-Platform Prediction Market Smart-Money Tracker & Analytics">
|
<meta name="description" content="Predictalytics — Multi-Platform Prediction Market Smart-Money Tracker & Analytics">
|
||||||
<title>Predictalytics</title>
|
<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">
|
<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>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Predictalytics — See every trader and market before the crowd does</title>
|
<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 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">
|
<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>
|
<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]
|
[Fact]
|
||||||
public void WebserverUrl_falls_back_to_localhost_when_host_is_blank()
|
public void WebserverUrl_falls_back_to_localhost_when_host_is_blank()
|
||||||
{
|
{
|
||||||
var options = new PredictalyticsOptions { WebserverHost = " ", WebserverPort = 5000 };
|
var options = new PredictalyticsOptions { WebserverHost = " ", WebserverPort = 4000 };
|
||||||
Assert.Equal("http://localhost:5000", options.WebserverUrl);
|
Assert.Equal("http://localhost:4000", options.WebserverUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 & ADMINISTRATION</div>
|
||||||
|
<p class="login-hint">Dieses Interface ist passwortgeschützt. Bitte geben Sie den in den Einstellungen hinterlegten Admin-Token ein.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="login-form" onsubmit="handleLogin(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="admin-token-input">Admin-Token</label>
|
||||||
|
<input type="password" id="admin-token-input" placeholder="Token eingeben..." required autocomplete="current-password" autofocus />
|
||||||
|
</div>
|
||||||
|
<div id="login-error" class="login-error" style="display: none;"></div>
|
||||||
|
<button type="submit" id="login-btn" class="btn btn-primary btn-block">Entsperren & Anmelden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── HAUPT-DASHBOARD (initial ausgeblendet) ─── -->
|
||||||
|
<div id="app-container" class="app-layout" style="display: none;">
|
||||||
|
|
||||||
|
<!-- TOPBAR -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-title">Predictalytics</span>
|
||||||
|
<span class="badge badge-admin">ADMIN</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-indicators">
|
||||||
|
<div id="status-workers-pill" class="status-pill status-stopped">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span id="status-workers-text">Workers: Gestoppt</span>
|
||||||
|
</div>
|
||||||
|
<div id="status-web-pill" class="status-pill status-stopped">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span id="status-web-text">Webserver: Gestoppt</span>
|
||||||
|
</div>
|
||||||
|
<div id="status-dbsize-pill" class="status-pill">
|
||||||
|
<span>DB: <strong id="status-dbsize-text">—</strong></span>
|
||||||
|
</div>
|
||||||
|
<div id="busy-indicator" class="busy-pill" style="display: none;">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span id="busy-operation-text">Wird ausgeführt...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-right">
|
||||||
|
<button id="btn-toggle-workers" class="btn btn-sm btn-outline" onclick="toggleWorkers()">
|
||||||
|
▶ Start Server
|
||||||
|
</button>
|
||||||
|
<button id="btn-toggle-webserver" class="btn btn-sm btn-outline" onclick="toggleWebserver()">
|
||||||
|
▶ Start Webserver
|
||||||
|
</button>
|
||||||
|
<div class="topbar-divider"></div>
|
||||||
|
<span id="header-version" class="version-tag">v—</span>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="handleLogout()" title="Sitzung beenden">
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- WORKSPACE (SIDEBAR + CONTENT) -->
|
||||||
|
<div class="workspace">
|
||||||
|
|
||||||
|
<!-- SIDEBAR NAVIGATION -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<nav class="nav-menu">
|
||||||
|
<a href="#dashboard" class="nav-link active" data-tab="dashboard">
|
||||||
|
<span class="nav-icon">📊</span>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a href="#terminal" class="nav-link" data-tab="terminal">
|
||||||
|
<span class="nav-icon">💻</span>
|
||||||
|
<span>Terminal & Logs</span>
|
||||||
|
</a>
|
||||||
|
<a href="#logfiles" class="nav-link" data-tab="logfiles">
|
||||||
|
<span class="nav-icon">📁</span>
|
||||||
|
<span>Log-Dateien</span>
|
||||||
|
</a>
|
||||||
|
<a href="#maintenance" class="nav-link" data-tab="maintenance">
|
||||||
|
<span class="nav-icon">🛠️</span>
|
||||||
|
<span>Wartung</span>
|
||||||
|
</a>
|
||||||
|
<a href="#settings" class="nav-link" data-tab="settings">
|
||||||
|
<span class="nav-icon">⚙️</span>
|
||||||
|
<span>Einstellungen</span>
|
||||||
|
</a>
|
||||||
|
<a href="#license" class="nav-link" data-tab="license">
|
||||||
|
<span class="nav-icon">📜</span>
|
||||||
|
<span>Lizenz & Updates</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="system-mini-info">
|
||||||
|
<div>RAM: <strong id="sys-memory">—</strong></div>
|
||||||
|
<div>Uptime: <strong id="sys-uptime">—</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- CONTENT PANELS -->
|
||||||
|
<main class="content-area">
|
||||||
|
|
||||||
|
<!-- 1. DASHBOARD -->
|
||||||
|
<section id="panel-dashboard" class="tab-panel active">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>System-Übersicht</h2>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="refreshStatus()">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-title">Hintergrund-Worker (Data Crawler & Scoring)</div>
|
||||||
|
<div class="stat-value" id="dash-workers-state">Gestoppt</div>
|
||||||
|
<p class="stat-desc">Polymarket, Limitless, Azuro und Analyse-Pipelines.</p>
|
||||||
|
<div class="stat-actions">
|
||||||
|
<button id="dash-btn-workers" class="btn btn-sm btn-primary" onclick="toggleWorkers()">Worker starten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-title">Analyse-Webserver (Öffentliche WebUI)</div>
|
||||||
|
<div class="stat-value" id="dash-web-state">Gestoppt</div>
|
||||||
|
<p class="stat-desc" id="dash-web-url">Port: 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 & Indizes</p>
|
||||||
|
<div class="stat-actions">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="triggerDbUpdate()">Schema aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-title">System-Ressourcen</div>
|
||||||
|
<div class="stat-value" id="dash-memory">— MB</div>
|
||||||
|
<p class="stat-desc" id="dash-uptime">Uptime: —</p>
|
||||||
|
<div class="stat-actions">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="switchTab('terminal')">Terminal ansehen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schnellaktionen -->
|
||||||
|
<div class="section-box">
|
||||||
|
<h3>Häufige Verwaltungsaktionen</h3>
|
||||||
|
<div class="actions-row">
|
||||||
|
<button class="btn btn-secondary" onclick="triggerMarketSync()">🏛️ Märkte synchronisieren</button>
|
||||||
|
<button class="btn btn-secondary" onclick="triggerDbUpdate()">🗄️ Datenbank aktualisieren</button>
|
||||||
|
<button class="btn btn-secondary" onclick="checkForUpdates()">⬆️ Nach Updates suchen</button>
|
||||||
|
<button class="btn btn-warning" onclick="openRecalculateModal()">🔄 Alle Trader neu berechnen…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 2. TERMINAL & LOGS -->
|
||||||
|
<section id="panel-terminal" class="tab-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Live-Terminal</h2>
|
||||||
|
<div class="terminal-controls">
|
||||||
|
<label class="toggle-control">
|
||||||
|
<input type="checkbox" id="term-autoscroll" checked />
|
||||||
|
<span>Autoscroll</span>
|
||||||
|
</label>
|
||||||
|
<select id="term-level-filter" class="form-select-sm" onchange="filterTerminalLogs()">
|
||||||
|
<option value="ALL">Alle Stufen</option>
|
||||||
|
<option value="INF">Info & höher</option>
|
||||||
|
<option value="WRN">Nur Warnungen & Fehler</option>
|
||||||
|
<option value="ERR">Nur Fehler</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="clearTerminal()">Terminal leeren</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="reconnectTerminalStream()">Neu verbinden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="terminal-wrapper">
|
||||||
|
<div id="terminal-body" class="terminal-body">
|
||||||
|
<div class="log-line log-inf"><span>Terminal initialisiert. Verbinde mit Live-Stream...</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 3. LOG-DATEIEN -->
|
||||||
|
<section id="panel-logfiles" class="tab-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Log-Dateien</h2>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="loadLogFiles()">Liste aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-hint">Übersicht aller auf der Maschine abgelegten Log-Dateien nach Kategorien (all, info, workers, platforms). Jede Datei kann direkt heruntergeladen werden.</p>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kategorie</th>
|
||||||
|
<th>Dateiname</th>
|
||||||
|
<th class="num">Größe</th>
|
||||||
|
<th>Geändert (UTC)</th>
|
||||||
|
<th class="actions-col">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="logfiles-table-body">
|
||||||
|
<tr><td colspan="5" class="loading-cell">Lade Log-Dateien...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 4. WARTUNG -->
|
||||||
|
<section id="panel-maintenance" class="tab-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Wartungs- & Entwicklungsaktionen</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="maintenance-grid">
|
||||||
|
|
||||||
|
<div class="maintenance-card">
|
||||||
|
<div class="card-icon">🏛️</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>Märkte synchronisieren</h3>
|
||||||
|
<p>Führt einen vollständigen Einmal-Sync aller Märkte und Events für Polymarket, Limitless und Azuro durch. Aktualisiert Titel, Auflösungszustände und Quoten.</p>
|
||||||
|
<div class="card-badge">Voraussetzung: Worker müssen gestoppt sein</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-action">
|
||||||
|
<button class="btn btn-secondary" onclick="triggerMarketSync()">Markt-Sync starten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="maintenance-card">
|
||||||
|
<div class="card-icon">🗄️</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>Datenbank aktualisieren</h3>
|
||||||
|
<p>Wendet ausstehende Entity Framework Core Migrationen auf die konfigurierte Datenbank an und stellt das Schema sicher.</p>
|
||||||
|
<div class="card-badge">Voraussetzung: Worker müssen gestoppt sein</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-action">
|
||||||
|
<button class="btn btn-secondary" onclick="triggerDbUpdate()">Datenbank aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="maintenance-card danger-border">
|
||||||
|
<div class="card-icon">🔄</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>Alle Trader neu berechnen</h3>
|
||||||
|
<p>Löscht alle <strong>abgeleiteten</strong> Analysedaten (Positionen, Tages-Snapshots, Kategorie-Statistiken) und reiht sämtliche Trader zur vollständigen Neuberechnung mit der aktuellen Engine ein. Rohdaten (Trades und Märkte) bleiben unberührt.</p>
|
||||||
|
<div class="card-badge badge-warning">Achtung: Bereinigt Snapshots & Zwischenwerte</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-action">
|
||||||
|
<button class="btn btn-danger" onclick="openRecalculateModal()">Neuberechnung ausführen…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 5. EINSTELLUNGEN -->
|
||||||
|
<section id="panel-settings" class="tab-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Einstellungen</h2>
|
||||||
|
<button class="btn btn-primary" onclick="saveSettings()">Speichern & Übernehmen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="settings-form" onsubmit="event.preventDefault(); saveSettings();">
|
||||||
|
|
||||||
|
<!-- VERWALTUNGS-WEBSERVER -->
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="group-header">Verwaltungs-Webserver (Dieses Admin-WebGUI)</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Admin-Port</label>
|
||||||
|
<input type="number" id="cfg-admin-port" min="1" max="65535" required />
|
||||||
|
<span class="field-hint">Standard: 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 & Analyse-WebUI)</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Port</label>
|
||||||
|
<input type="number" id="cfg-web-port" min="1" max="65535" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Bind-Adresse</label>
|
||||||
|
<input type="text" id="cfg-web-host" required />
|
||||||
|
<span class="field-hint">'localhost' = lokal; '0.0.0.0' = von außen erreichbar.</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>API-Token (Steuerbefehle)</label>
|
||||||
|
<input type="password" id="cfg-web-token" placeholder="••••••••" />
|
||||||
|
<span class="field-hint">Schützt schreibende API-Befehle (X-Predictalytics-Key).</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Datenbank-Debug</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="cfg-web-dbdebug" />
|
||||||
|
<span>Detaillierte Verbindungs- und SQL-Infos loggen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DATENBANK -->
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="group-header">Datenbank (MySQL / MariaDB)</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Server</label>
|
||||||
|
<input type="text" id="cfg-db-server" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Datenbank</label>
|
||||||
|
<input type="text" id="cfg-db-name" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Benutzer</label>
|
||||||
|
<input type="text" id="cfg-db-user" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Passwort</label>
|
||||||
|
<input type="password" id="cfg-db-password" placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>SSL-Modus</label>
|
||||||
|
<select id="cfg-db-ssl">
|
||||||
|
<option value="None">None (Unverschlüsselt)</option>
|
||||||
|
<option value="Preferred">Preferred (Empfohlen für entfernte Server)</option>
|
||||||
|
<option value="Required">Required (SSL erzwungen)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DEPLOYMENT CENTER -->
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="group-header">Deployment Center</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Server-URL</label>
|
||||||
|
<input type="text" id="cfg-dc-url" readonly disabled class="input-disabled" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>API Token</label>
|
||||||
|
<input type="password" id="cfg-dc-token" placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Heartbeat aktiv</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="cfg-dc-heartbeat" />
|
||||||
|
<span>Dead-Man's-Switch Pings an Watchdog senden</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Monitor Source & Instance</label>
|
||||||
|
<div class="input-dual">
|
||||||
|
<input type="text" id="cfg-dc-source" placeholder="Source (Predictalytics)" />
|
||||||
|
<input type="text" id="cfg-dc-instance" placeholder="Instance (default)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Intervall (Sekunden)</label>
|
||||||
|
<input type="number" id="cfg-dc-interval" min="15" max="3600" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Fehler melden</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="cfg-dc-errorreporting" />
|
||||||
|
<span>Error/Fatal-Meldungen automatisch an das Deployment Center streamen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Update-Prüfung</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="cfg-dc-updatecheck" />
|
||||||
|
<span>Beim Start auf neuere Versionen prüfen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Update-Kanal</label>
|
||||||
|
<select id="cfg-dc-channel">
|
||||||
|
<option value="prod">prod (Produktion)</option>
|
||||||
|
<option value="beta">beta (Vorab-Versionen)</option>
|
||||||
|
<option value="dev">dev (Entwicklung)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EGRESS -->
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="group-header">Egress (Proxies & Quell-IPs)</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Egress-Kanäle</label>
|
||||||
|
<textarea id="cfg-egress-channels" rows="4" placeholder="prox-1|Proxy|http://user:pass@proxy:8080 ip-1|SourceIp|192.168.1.100"></textarea>
|
||||||
|
<span class="field-hint">Format: id|type|value (Zeilengetrennt)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-submit-row">
|
||||||
|
<button type="submit" class="btn btn-primary btn-large">Einstellungen speichern & übernehmen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 6. LIZENZ & UPDATES -->
|
||||||
|
<section id="panel-license" class="tab-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Lizenzstatus & Deployment Center</h2>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="checkForUpdates()">Auf Updates prüfen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-title">Produkt & Version</div>
|
||||||
|
<div class="stat-value" id="lic-product">—</div>
|
||||||
|
<p class="stat-desc" id="lic-version">Version: —</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-title">Hardware-Identifikation (HWID)</div>
|
||||||
|
<div class="stat-value font-mono" id="lic-hwid" style="font-size: 14px;">—</div>
|
||||||
|
<p class="stat-desc" id="lic-hwidsource">Quelle: —</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-title">Lizenzprüfung</div>
|
||||||
|
<div class="stat-value" id="lic-status">—</div>
|
||||||
|
<p class="stat-desc" id="lic-message">—</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="update-banner" class="section-box" style="display: none; margin-top: 24px;">
|
||||||
|
<h3 id="update-banner-title">⬆️ Neues Update verfügbar</h3>
|
||||||
|
<div id="update-banner-body"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── BESTÄTIGUNGS-MODAL FÜR NEUBERECHNUNG ─── -->
|
||||||
|
<div id="confirm-modal" class="modal-backdrop" style="display: none;">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-icon">⚠️</span>
|
||||||
|
<h3>Alle Trader neu berechnen</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Dies löscht alle <strong>abgeleiteten Analysedaten</strong> (Positionen, Tages-Snapshots, Kategorie-Statistiken) und markiert jeden Trader zur vollständigen Neuberechnung.</p>
|
||||||
|
<p>Rohdaten (Trades und Märkte) bleiben unberührt.</p>
|
||||||
|
<p>Danach muss der Server gestartet werden: Der Analytics-Worker baut jeden Trader mit der aktuellen Engine neu auf (kann je nach Datenbestand mehrere Stunden dauern).</p>
|
||||||
|
<p><strong>Möchten Sie den Vorgang wirklich starten?</strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeRecalculateModal()">Abbrechen</button>
|
||||||
|
<button class="btn btn-danger" onclick="executeRecalculateAll()">Ja, Daten zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOAST NOTIFICATION CONTAINER -->
|
||||||
|
<div id="toast-container" class="toast-container"></div>
|
||||||
|
|
||||||
|
<script src="js/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,734 @@
|
|||||||
|
/**
|
||||||
|
* Predictalytics Administration WebGUI JavaScript
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'predictalytics_admin_token';
|
||||||
|
let currentToken = null;
|
||||||
|
let statusPollInterval = null;
|
||||||
|
let terminalEventSource = null;
|
||||||
|
let allLogs = [];
|
||||||
|
let maxLogEntries = 600;
|
||||||
|
|
||||||
|
// ─── INITIALISIERUNG & AUTHENTIFIZIERUNG ───
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setupNavigation();
|
||||||
|
|
||||||
|
// Prüfe, ob bereits ein Token in sessionStorage hinterlegt ist
|
||||||
|
const savedToken = sessionStorage.getItem(STORAGE_KEY) || localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (savedToken) {
|
||||||
|
verifyAndUnlock(savedToken);
|
||||||
|
} else {
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showLogin(errorMessage = null) {
|
||||||
|
document.getElementById('login-overlay').style.display = 'flex';
|
||||||
|
document.getElementById('app-container').style.display = 'none';
|
||||||
|
if (statusPollInterval) clearInterval(statusPollInterval);
|
||||||
|
if (terminalEventSource) {
|
||||||
|
terminalEventSource.close();
|
||||||
|
terminalEventSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errBox = document.getElementById('login-error');
|
||||||
|
if (errorMessage) {
|
||||||
|
errBox.textContent = errorMessage;
|
||||||
|
errBox.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
errBox.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenInput = document.getElementById('admin-token-input');
|
||||||
|
tokenInput.value = '';
|
||||||
|
tokenInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const tokenInput = document.getElementById('admin-token-input');
|
||||||
|
const token = tokenInput.value.trim();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('login-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Prüfe...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/auth/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Admin-Token': token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
currentToken = token;
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, token);
|
||||||
|
unlockApp();
|
||||||
|
} else {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
showLogin(data.message || 'Ungültiger Admin-Token. Zugriff verweigert.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showLogin('Verbindungsfehler zum Admin-Server: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Entsperren & Anmelden';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAndUnlock(token) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/auth/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Admin-Token': token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
currentToken = token;
|
||||||
|
unlockApp();
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
showLogin('Sitzung abgelaufen oder Token ungültig.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showLogin('Server nicht erreichbar.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockApp() {
|
||||||
|
document.getElementById('login-overlay').style.display = 'none';
|
||||||
|
document.getElementById('app-container').style.display = 'flex';
|
||||||
|
|
||||||
|
// Status sofort laden und Polling starten
|
||||||
|
refreshStatus();
|
||||||
|
statusPollInterval = setInterval(refreshStatus, 3000);
|
||||||
|
|
||||||
|
// Initiales Terminal-Streaming starten
|
||||||
|
connectTerminalStream();
|
||||||
|
|
||||||
|
// Log-Dateien & Settings vorbereiten
|
||||||
|
loadLogFiles();
|
||||||
|
loadSettings();
|
||||||
|
loadLicenseInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
currentToken = null;
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
showLogin('Erfolgreich abgemeldet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API HELPER ───
|
||||||
|
|
||||||
|
async function api(endpoint, options = {}) {
|
||||||
|
if (!options.headers) options.headers = {};
|
||||||
|
if (currentToken) {
|
||||||
|
options.headers['X-Admin-Token'] = currentToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpoint, options);
|
||||||
|
if (res.status === 401) {
|
||||||
|
handleLogout();
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('API Error (' + endpoint + '):', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── STATUS & STEUERUNG ───
|
||||||
|
|
||||||
|
let lastStatus = {};
|
||||||
|
|
||||||
|
async function refreshStatus() {
|
||||||
|
try {
|
||||||
|
const res = await api('/api/admin/status');
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
lastStatus = data;
|
||||||
|
|
||||||
|
// Topbar Status-Pills
|
||||||
|
const workersPill = document.getElementById('status-workers-pill');
|
||||||
|
const workersText = document.getElementById('status-workers-text');
|
||||||
|
const btnWorkers = document.getElementById('btn-toggle-workers');
|
||||||
|
const dashBtnWorkers = document.getElementById('dash-btn-workers');
|
||||||
|
const dashWorkersState = document.getElementById('dash-workers-state');
|
||||||
|
|
||||||
|
if (data.workersRunning) {
|
||||||
|
workersPill.className = 'status-pill status-running';
|
||||||
|
workersText.textContent = 'Workers: Aktiv';
|
||||||
|
btnWorkers.textContent = '⏹ Stop Server';
|
||||||
|
dashBtnWorkers.textContent = 'Worker stoppen';
|
||||||
|
dashBtnWorkers.className = 'btn btn-sm btn-secondary';
|
||||||
|
dashWorkersState.textContent = 'Läuft';
|
||||||
|
dashWorkersState.style.color = '#10b981';
|
||||||
|
} else {
|
||||||
|
workersPill.className = 'status-pill status-stopped';
|
||||||
|
workersText.textContent = 'Workers: Gestoppt';
|
||||||
|
btnWorkers.textContent = '▶ Start Server';
|
||||||
|
dashBtnWorkers.textContent = 'Worker starten';
|
||||||
|
dashBtnWorkers.className = 'btn btn-sm btn-primary';
|
||||||
|
dashWorkersState.textContent = 'Gestoppt';
|
||||||
|
dashWorkersState.style.color = '#94a3b8';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webserver Status
|
||||||
|
const webPill = document.getElementById('status-web-pill');
|
||||||
|
const webText = document.getElementById('status-web-text');
|
||||||
|
const btnWeb = document.getElementById('btn-toggle-webserver');
|
||||||
|
const dashBtnWeb = document.getElementById('dash-btn-web');
|
||||||
|
const dashWebState = document.getElementById('dash-web-state');
|
||||||
|
const dashWebUrl = document.getElementById('dash-web-url');
|
||||||
|
const dashLinkWeb = document.getElementById('dash-link-web');
|
||||||
|
|
||||||
|
if (data.webServerRunning) {
|
||||||
|
webPill.className = 'status-pill status-running';
|
||||||
|
webText.textContent = 'Webserver :' + data.webServerPort;
|
||||||
|
btnWeb.textContent = '⏹ Stop Webserver';
|
||||||
|
dashBtnWeb.textContent = 'Webserver stoppen';
|
||||||
|
dashBtnWeb.className = 'btn btn-sm btn-secondary';
|
||||||
|
dashWebState.textContent = 'Läuft :' + data.webServerPort;
|
||||||
|
dashWebState.style.color = '#10b981';
|
||||||
|
dashWebUrl.textContent = data.webServerUrl;
|
||||||
|
dashLinkWeb.href = data.webServerUrl;
|
||||||
|
dashLinkWeb.style.display = 'inline-flex';
|
||||||
|
} else {
|
||||||
|
webPill.className = 'status-pill status-stopped';
|
||||||
|
webText.textContent = 'Webserver: Gestoppt';
|
||||||
|
btnWeb.textContent = '▶ Start Webserver';
|
||||||
|
dashBtnWeb.textContent = 'Webserver starten';
|
||||||
|
dashBtnWeb.className = 'btn btn-sm btn-primary';
|
||||||
|
dashWebState.textContent = 'Gestoppt';
|
||||||
|
dashWebState.style.color = '#94a3b8';
|
||||||
|
dashWebUrl.textContent = 'Port: ' + data.webServerPort;
|
||||||
|
dashLinkWeb.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB Size
|
||||||
|
const dbText = document.getElementById('status-dbsize-text');
|
||||||
|
const dashDbText = document.getElementById('dash-db-size');
|
||||||
|
const dbFormatted = data.dbSizeMb != null ? Number(data.dbSizeMb).toFixed(2) + ' MB' : '—';
|
||||||
|
dbText.textContent = dbFormatted;
|
||||||
|
dashDbText.textContent = dbFormatted;
|
||||||
|
|
||||||
|
// Busy Indicator
|
||||||
|
const busyIndicator = document.getElementById('busy-indicator');
|
||||||
|
const busyText = document.getElementById('busy-operation-text');
|
||||||
|
if (data.isBusy) {
|
||||||
|
busyIndicator.style.display = 'flex';
|
||||||
|
busyText.textContent = data.busyOperation || 'Wird ausgeführt...';
|
||||||
|
} else {
|
||||||
|
busyIndicator.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version & Uptime
|
||||||
|
document.getElementById('header-version').textContent = 'v' + data.appVersion;
|
||||||
|
document.getElementById('sys-memory').textContent = data.memoryMb + ' MB';
|
||||||
|
document.getElementById('dash-memory').textContent = data.memoryMb + ' MB';
|
||||||
|
document.getElementById('sys-uptime').textContent = data.uptimeText;
|
||||||
|
document.getElementById('dash-uptime').textContent = 'Uptime: ' + data.uptimeText;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Status poll failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleWorkers() {
|
||||||
|
const isRunning = lastStatus.workersRunning;
|
||||||
|
const action = isRunning ? 'stop' : 'start';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api('/api/admin/workers/' + action, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(data.message || 'Worker-Status geändert.', res.ok ? 'success' : 'error');
|
||||||
|
refreshStatus();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Aktion fehlgeschlagen: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleWebserver() {
|
||||||
|
const isRunning = lastStatus.webServerRunning;
|
||||||
|
const action = isRunning ? 'stop' : 'start';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api('/api/admin/webserver/' + action, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(data.message || 'Webserver-Status geändert.', res.ok ? 'success' : 'error');
|
||||||
|
refreshStatus();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Aktion fehlgeschlagen: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WARTUNGSAKTIONEN ───
|
||||||
|
|
||||||
|
async function triggerMarketSync() {
|
||||||
|
if (lastStatus.workersRunning) {
|
||||||
|
showToast('Markt-Sync kann nicht laufen, während die Worker aktiv sind. Bitte zuerst Worker stoppen.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Manueller Markt-Sync gestartet...', 'success');
|
||||||
|
refreshStatus();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api('/api/admin/maintenance/sync-markets', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(data.message || 'Markt-Sync erfolgreich abgeschlossen.', 'success');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Markt-Sync fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Fehler: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
refreshStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerDbUpdate() {
|
||||||
|
if (lastStatus.workersRunning) {
|
||||||
|
showToast('Datenbank-Update kann nicht laufen, während die Worker aktiv sind.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Datenbank-Update gestartet...', 'success');
|
||||||
|
refreshStatus();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api('/api/admin/maintenance/update-database', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(data.message || 'Datenbank erfolgreich aktualisiert.', 'success');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Datenbank-Update fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Fehler: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
refreshStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRecalculateModal() {
|
||||||
|
if (lastStatus.workersRunning) {
|
||||||
|
showToast('Neuberechnung kann nicht laufen, während die Worker aktiv sind. Bitte zuerst Worker stoppen.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('confirm-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRecalculateModal() {
|
||||||
|
document.getElementById('confirm-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRecalculateAll() {
|
||||||
|
closeRecalculateModal();
|
||||||
|
showToast('Neuberechnung aller Trader eingeleitet...', 'warning');
|
||||||
|
refreshStatus();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api('/api/admin/maintenance/recalculate-traders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ confirm: true })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Neuberechnung erfolgreich eingeleitet: ' + (data.summary ? data.summary.split('\n')[0] : 'Fertig'), 'success');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Neuberechnung fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Fehler: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
refreshStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── LIVE-TERMINAL (SSE) ───
|
||||||
|
|
||||||
|
function connectTerminalStream() {
|
||||||
|
if (terminalEventSource) {
|
||||||
|
terminalEventSource.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = '/api/admin/logs/stream?token=' + encodeURIComponent(currentToken);
|
||||||
|
terminalEventSource = new EventSource(url);
|
||||||
|
|
||||||
|
terminalEventSource.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(e.data);
|
||||||
|
if (entry && entry.Message) {
|
||||||
|
appendLogLine(entry);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignoriere Ping / Non-JSON
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
terminalEventSource.onerror = (e) => {
|
||||||
|
console.warn('Terminal EventSource error / disconnected.');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectTerminalStream() {
|
||||||
|
appendLogLine({
|
||||||
|
LevelText: 'INF',
|
||||||
|
Message: 'Verbinde neu mit Live-Log-Stream...',
|
||||||
|
Timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
connectTerminalStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLogLine(entry) {
|
||||||
|
allLogs.push(entry);
|
||||||
|
if (allLogs.length > maxLogEntries) {
|
||||||
|
allLogs.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLogLine(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogLine(entry) {
|
||||||
|
const filter = document.getElementById('term-level-filter').value;
|
||||||
|
if (!matchesFilter(entry.LevelText, filter)) return;
|
||||||
|
|
||||||
|
const termBody = document.getElementById('terminal-body');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
const lvl = (entry.LevelText || 'INF').toLowerCase();
|
||||||
|
div.className = 'log-line log-' + lvl;
|
||||||
|
|
||||||
|
const ts = entry.Timestamp ? new Date(entry.Timestamp).toLocaleTimeString('de-DE') : '';
|
||||||
|
let text = `[${ts}] [${entry.LevelText || 'INF'}] ${entry.Message}`;
|
||||||
|
if (entry.Exception) {
|
||||||
|
text += `\n ⚠ ${entry.Exception}`;
|
||||||
|
}
|
||||||
|
div.textContent = text;
|
||||||
|
termBody.appendChild(div);
|
||||||
|
|
||||||
|
// Auf 500 Zeilen begrenzen
|
||||||
|
while (termBody.children.length > 500) {
|
||||||
|
termBody.removeChild(termBody.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoscroll = document.getElementById('term-autoscroll').checked;
|
||||||
|
if (autoscroll) {
|
||||||
|
termBody.scrollTop = termBody.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterTerminalLogs() {
|
||||||
|
const termBody = document.getElementById('terminal-body');
|
||||||
|
termBody.innerHTML = '';
|
||||||
|
const filter = document.getElementById('term-level-filter').value;
|
||||||
|
|
||||||
|
allLogs.forEach(entry => {
|
||||||
|
if (matchesFilter(entry.LevelText, filter)) {
|
||||||
|
renderLogLine(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(levelText, filter) {
|
||||||
|
if (filter === 'ALL') return true;
|
||||||
|
if (filter === 'INF') return ['INF', 'WRN', 'ERR', 'FTL'].includes(levelText);
|
||||||
|
if (filter === 'WRN') return ['WRN', 'ERR', 'FTL'].includes(levelText);
|
||||||
|
if (filter === 'ERR') return ['ERR', 'FTL'].includes(levelText);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTerminal() {
|
||||||
|
allLogs = [];
|
||||||
|
document.getElementById('terminal-body').innerHTML = '<div class="log-line log-inf"><span>Terminal geleert.</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── LOG-DATEIEN ───
|
||||||
|
|
||||||
|
async function loadLogFiles() {
|
||||||
|
const tbody = document.getElementById('logfiles-table-body');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="loading-cell">Lade Log-Dateien...</td></tr>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api('/api/admin/logs/files');
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Laden');
|
||||||
|
const files = await res.json();
|
||||||
|
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="loading-cell">Keine Log-Dateien gefunden.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
files.forEach(f => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const dateStr = f.modifiedUtc ? new Date(f.modifiedUtc).toLocaleString('de-DE') : '—';
|
||||||
|
const dlUrl = `/api/admin/logs/files/download?file=${encodeURIComponent(f.relativePath)}&token=${encodeURIComponent(currentToken)}`;
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><span class="badge" style="background:#1e293b;padding:2px 8px;border-radius:4px;">${escapeHtml(f.category)}</span></td>
|
||||||
|
<td class="font-mono"><strong>${escapeHtml(f.name)}</strong></td>
|
||||||
|
<td class="num">${f.sizeKb} KB</td>
|
||||||
|
<td>${dateStr}</td>
|
||||||
|
<td class="actions-col">
|
||||||
|
<a href="${dlUrl}" class="btn btn-sm btn-secondary" download="${escapeHtml(f.name)}">⬇️ Herunterladen</a>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="loading-cell" style="color:#ef4444;">Log-Dateien konnten nicht geladen werden.</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── EINSTELLUNGEN ───
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const res = await api('/api/admin/settings');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
if (data.adminServer) {
|
||||||
|
document.getElementById('cfg-admin-port').value = data.adminServer.port || 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);
|
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
|
config = config
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
// FILE SINKS — By Level
|
// FILE SINKS — By Level
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ public sealed class PredictalyticsHost
|
|||||||
private WebApplication? _app;
|
private WebApplication? _app;
|
||||||
private Task? _runTask;
|
private Task? _runTask;
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
|
private Predictalytics.Hosting.Admin.AdminWebServer? _adminServer;
|
||||||
|
private CancellationTokenSource? _workerCts;
|
||||||
|
private Task? _workerRunTask;
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
public PredictalyticsHost(PredictalyticsOptions options) => Options = options;
|
public PredictalyticsHost(PredictalyticsOptions options) => Options = options;
|
||||||
@@ -38,8 +41,45 @@ public sealed class PredictalyticsHost
|
|||||||
|
|
||||||
public bool WebServerRunning { get; private set; }
|
public bool WebServerRunning { get; private set; }
|
||||||
public bool WorkersRunning { 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
|
// Wartungsaktionen
|
||||||
@@ -280,10 +320,93 @@ public sealed class PredictalyticsHost
|
|||||||
RaiseStateChanged();
|
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
|
// 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>
|
/// <summary>
|
||||||
/// Startet die Hintergrund-Worker und laeuft, bis <paramref name="ct"/> ausgeloest wird.
|
/// Startet die Hintergrund-Worker und laeuft, bis <paramref name="ct"/> ausgeloest wird.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
@@ -21,8 +22,8 @@ public sealed class PredictalyticsOptions
|
|||||||
[Category("Webserver")]
|
[Category("Webserver")]
|
||||||
[DisplayName("Port")]
|
[DisplayName("Port")]
|
||||||
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
|
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
|
||||||
[DefaultValue(5000)]
|
[DefaultValue(4000)]
|
||||||
public int WebserverPort { get; set; } = 5000;
|
public int WebserverPort { get; set; } = 4000;
|
||||||
|
|
||||||
[Category("Webserver")]
|
[Category("Webserver")]
|
||||||
[DisplayName("Bind-Adresse")]
|
[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 ───
|
// ─── Egress ───
|
||||||
|
|
||||||
private string _egressChannelsText = "";
|
private string _egressChannelsText = "";
|
||||||
@@ -224,12 +279,40 @@ public sealed class PredictalyticsOptions
|
|||||||
{
|
{
|
||||||
var options = JsonSerializer.Deserialize<PredictalyticsOptions>(File.ReadAllText(path))
|
var options = JsonSerializer.Deserialize<PredictalyticsOptions>(File.ReadAllText(path))
|
||||||
?? new PredictalyticsOptions();
|
?? 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;
|
return options;
|
||||||
}
|
}
|
||||||
catch
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<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>
|
</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>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" />
|
<PackageReference Include="Avalonia" />
|
||||||
<PackageReference Include="Avalonia.Desktop" />
|
<PackageReference Include="Avalonia.Desktop" />
|
||||||
@@ -32,6 +42,10 @@
|
|||||||
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
|
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
|
||||||
CopyToOutputDirectory="PreserveNewest"
|
CopyToOutputDirectory="PreserveNewest"
|
||||||
CopyToPublishDirectory="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" />
|
<None Include="run.sh" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ internal static class HeadlessRunner
|
|||||||
|
|
||||||
heartbeat = StartHeartbeat(options, host);
|
heartbeat = StartHeartbeat(options, host);
|
||||||
|
|
||||||
|
if (options.AdminServerEnabled)
|
||||||
|
{
|
||||||
|
await host.StartAdminServerAsync();
|
||||||
|
Log.Information("Verwaltungs-WebGUI erreichbar unter {Url}", options.AdminServerUrl);
|
||||||
|
}
|
||||||
|
|
||||||
await host.StartWebServerAsync();
|
await host.StartWebServerAsync();
|
||||||
Log.Information("WebUI erreichbar unter {Url}", options.WebserverUrl);
|
Log.Information("WebUI erreichbar unter {Url}", options.WebserverUrl);
|
||||||
|
|
||||||
@@ -61,6 +67,10 @@ internal static class HeadlessRunner
|
|||||||
|
|
||||||
heartbeat?.NotifyStopping();
|
heartbeat?.NotifyStopping();
|
||||||
await host.StopWebServerAsync();
|
await host.StopWebServerAsync();
|
||||||
|
if (options.AdminServerEnabled)
|
||||||
|
{
|
||||||
|
await host.StopAdminServerAsync();
|
||||||
|
}
|
||||||
Log.Information("Dienst planmäßig beendet.");
|
Log.Information("Dienst planmäßig beendet.");
|
||||||
return ExitOk;
|
return ExitOk;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
private const int MaxLogLines = 500;
|
private const int MaxLogLines = 500;
|
||||||
|
|
||||||
private readonly PredictalyticsHost _host;
|
private readonly PredictalyticsHost _host;
|
||||||
private CancellationTokenSource? _workerCts;
|
|
||||||
private DcHeartbeatService? _heartbeat;
|
private DcHeartbeatService? _heartbeat;
|
||||||
private double? _lastDbSizeMb;
|
private double? _lastDbSizeMb;
|
||||||
|
|
||||||
@@ -79,6 +78,22 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
|
|
||||||
RestartHeartbeat();
|
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)
|
if (Options.DcUpdateCheckEnabled)
|
||||||
{
|
{
|
||||||
_ = CheckForUpdatesAsync(silent: true);
|
_ = CheckForUpdatesAsync(silent: true);
|
||||||
@@ -292,23 +307,17 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
// ─── Befehle ───
|
// ─── Befehle ───
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ToggleServerAsync()
|
private void ToggleServer()
|
||||||
{
|
{
|
||||||
if (!_host.WorkersRunning)
|
if (!_host.WorkersRunning)
|
||||||
{
|
{
|
||||||
_workerCts = new CancellationTokenSource();
|
|
||||||
Log.Information("🚀 Starte Hintergrund-Worker...");
|
Log.Information("🚀 Starte Hintergrund-Worker...");
|
||||||
try
|
_host.StartWorkers();
|
||||||
{
|
|
||||||
await _host.StartWorkersAsync(_workerCts.Token);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) { }
|
|
||||||
catch (Exception ex) { Log.Error(ex, "Worker error"); }
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Log.Information("⏹ Stoppe Hintergrund-Worker...");
|
Log.Information("⏹ Stoppe Hintergrund-Worker...");
|
||||||
_workerCts?.Cancel();
|
_host.StopWorkers();
|
||||||
Log.Information("Worker gestoppt.");
|
Log.Information("Worker gestoppt.");
|
||||||
}
|
}
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
@@ -354,6 +363,17 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenWebUi() => OpenInShell(Options.WebserverUrl);
|
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]
|
[RelayCommand]
|
||||||
private void OpenLogFolder()
|
private void OpenLogFolder()
|
||||||
{
|
{
|
||||||
@@ -476,7 +496,8 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
_heartbeat?.NotifyStopping();
|
_heartbeat?.NotifyStopping();
|
||||||
_heartbeat?.Dispose();
|
_heartbeat?.Dispose();
|
||||||
_heartbeat = null;
|
_heartbeat = null;
|
||||||
_workerCts?.Cancel();
|
_host.StopWorkers();
|
||||||
try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ }
|
try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ }
|
||||||
|
try { _host.StopAdminServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ public static class Dialogs
|
|||||||
MaxWidth = 640,
|
MaxWidth = 640,
|
||||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||||
CanResize = false,
|
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++)
|
for (var i = 0; i < buttons.Length; i++)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="Predictalytics.Shell.Views.LicenseWindow"
|
x:Class="Predictalytics.Shell.Views.LicenseWindow"
|
||||||
Title="Predictalytics — Lizenzaktivierung"
|
Title="Predictalytics — Lizenzaktivierung"
|
||||||
|
Icon="/Assets/predictalytics.png"
|
||||||
Width="580" SizeToContent="Height"
|
Width="580" SizeToContent="Height"
|
||||||
CanResize="False"
|
CanResize="False"
|
||||||
WindowStartupLocation="CenterScreen">
|
WindowStartupLocation="CenterScreen">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
x:Class="Predictalytics.Shell.Views.MainWindow"
|
x:Class="Predictalytics.Shell.Views.MainWindow"
|
||||||
x:DataType="vm:MainWindowViewModel"
|
x:DataType="vm:MainWindowViewModel"
|
||||||
Title="Predictalytics Analytics — Backend Server"
|
Title="Predictalytics Analytics — Backend Server"
|
||||||
|
Icon="/Assets/predictalytics.png"
|
||||||
Width="1400" Height="900"
|
Width="1400" Height="900"
|
||||||
MinWidth="900" MinHeight="600"
|
MinWidth="900" MinHeight="600"
|
||||||
WindowStartupLocation="CenterScreen">
|
WindowStartupLocation="CenterScreen">
|
||||||
@@ -41,7 +42,8 @@
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem Header="_Ansicht">
|
<MenuItem Header="_Ansicht">
|
||||||
<MenuItem Header="Log-Ordner öffnen" Command="{Binding OpenLogFolderCommand}" />
|
<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>
|
||||||
<MenuItem Header="_Entwicklung">
|
<MenuItem Header="_Entwicklung">
|
||||||
<MenuItem Header="Märkte synchronisieren" Command="{Binding SyncMarketsCommand}" />
|
<MenuItem Header="Märkte synchronisieren" Command="{Binding SyncMarketsCommand}" />
|
||||||
@@ -134,6 +136,36 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</HeaderedContentControl>
|
</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 -->
|
<!-- Datenbank -->
|
||||||
<HeaderedContentControl Classes="group" Header="Datenbank">
|
<HeaderedContentControl Classes="group" Header="Datenbank">
|
||||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
<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 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)
|
protected override void OnClosed(EventArgs e)
|
||||||
{
|
{
|
||||||
if (_vm != null)
|
if (_vm != null)
|
||||||
|
|||||||
Reference in New Issue
Block a user