Paralleles WebGUI mit Kestrel, Token-Sicherung und SSE-Logs implementiert

This commit is contained in:
Richard
2026-09-07 21:55:51 +02:00
parent 28b3aedec7
commit 10f928eb4e
36 changed files with 3114 additions and 266 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Plattformneutral: dieselbe Anwendung laeuft unter Windows und Linux. Loest schrittweise
@@ -11,6 +11,19 @@
<AssemblyName>PolyTrader.App.Avalonia</AssemblyName>
<RootNamespace>PolyTrader.App.Avalonia</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<!-- Symbol der ausfuehrbaren Datei unter Windows (Explorer, Taskleiste, Alt-Tab). Wirkt nur
beim Bauen fuer win-*: Der Apphost ist dort eine PE-Datei, in die das Symbol eingebettet
wird. Fuer linux-x64 entsteht eine ELF-Datei ohne Symbolabschnitt - dort traegt allein
das Fenstersymbol aus den drei Window-Dateien, das der Fenstermanager als _NET_WM_ICON
liest.
ZWEI DATEIEN, EIN BILD - und das ist kein Versehen: Assets/icon.ico fuer diese Zeile,
Assets/icon.png fuer die Fenster. Avalonia laedt ein Fenstersymbol ueber Skia, und Skia
dekodiert kein ICO. Mit Icon="...icon.ico" startet die Anwendung anstandslos, das Fenster
bleibt aber ohne Symbol: X11 setzt dann gar kein _NET_WM_ICON, und im Log steht nichts.
Am 01.09.2026 auf der Linux-VM genau so gemessen (xprop: "no such atom"). Wer die PNG
ersetzt, erzeugt sie neu aus der ICO - beide gehoeren zusammen. -->
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
<!-- Avalonia-Previewer und XAML-Compiler: Layout wird deklarativ in .axaml gebaut,
nicht zur Laufzeit im Code (Layout-Regel des Projekts). -->
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
@@ -29,9 +42,6 @@
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.13" />
<!-- Diagramme (Entscheidung: LiveCharts2 statt ScottPlot, aus optischen Gruenden). -->
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" Version="2.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
</ItemGroup>
<ItemGroup>
@@ -40,12 +50,22 @@
<ProjectReference Include="..\PolyTrader.Modules.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.csproj" />
<ProjectReference Include="..\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj" />
<ProjectReference Include="..\PolyTrader.Modules.Accounting\PolyTrader.Modules.Accounting.csproj" />
<ProjectReference Include="..\PolyTrader.Web\PolyTrader.Web.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<!-- Symbole der Oberflaeche (siehe Shell/ViewIcons.cs). Lagen bis zum WinForms-Ausbau im
Repo-Root unter Resources/ und wurden von dort verlinkt; jetzt liegen sie im Projekt,
wo sie hingehoeren. Der avares-Pfad "Assets/<datei>.png" bleibt dabei unveraendert. -->
<!-- Schliesst "icon.png" mit ein - das Fenstersymbol, das die drei Window-Dateien ueber
avares:// laden. Es muss eingebettet sein und nicht bloss neben der ausfuehrbaren Datei
liegen, sonst faellt es weg, sobald jemand das Installationsverzeichnis aufraeumt.
"icon.ico" ist bewusst KEINE Avalonia-Ressource: Die Datei wird nur zur Bauzeit von
ApplicationIcon gelesen, eingebettet waere sie 190 KB tote Last im Paket. -->
<AvaloniaResource Include="Assets\*.png" />
</ItemGroup>
@@ -55,6 +75,12 @@
<!-- D-5: vom Deploymentcenter-Installer gelesen (update-agent im Modus "install"). Gehört ins
Publish-Verzeichnis, landet damit im Release-Paket neben der manifest.json. -->
<None Update="setup.json" CopyToOutputDirectory="PreserveNewest" />
<!-- Dasselbe Bild noch einmal als Datei neben der Anwendung - nicht doppelt gemoppelt:
Der Fenstermanager unter Linux liest das Symbol fuer deploy/polytrader.desktop aus einer
Datei und kann in kein Assembly hineinsehen. Die eingebettete Fassung (AvaloniaResource
oben) bleibt fuer das Fenstersymbol zustaendig. -->
<Content Include="Assets\icon.png" Link="icon.png" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\PolyTrader.Web\wwwroot\**" Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+40 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -20,6 +20,8 @@ using PolyTrader.Modules.Supervisor;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
using PolyTrader.Web.Configuration;
using PolyTrader.Web.Hosting;
namespace PolyTrader.App.Avalonia
{
@@ -166,6 +168,22 @@ namespace PolyTrader.App.Avalonia
return config["Database:MySqlConnectionString"] ?? string.Empty;
}
/// <summary>
/// Lädt die WebUI-Konfiguration (Port, LocalOnly, AuthToken) aus den Konfigurationsdateien.
/// </summary>
internal static WebUiOptions LoadWebUiOptions()
{
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Local.json", optional: true)
.Build();
var options = new WebUiOptions();
config.GetSection(WebUiOptions.SectionName).Bind(options);
return options;
}
/// <summary>
/// Baut den Anwendungs-Host: Konfiguration, Master-Key, Persistenz, Core-Dienste und die
/// aktiven Module. Kennt die Oberfläche nicht.
@@ -235,6 +253,25 @@ namespace PolyTrader.App.Avalonia
var modules = allModules.Where(m => !disabled.Contains(m.Name)).ToList();
var webOptions = LoadWebUiOptions();
if (!enforceLicense)
{
// Bei Konstruktionsprüfung (--smoke-ui) kein Kestrel starten
webOptions.Enabled = false;
}
if (webOptions.Enabled)
{
string bind = webOptions.LocalOnly ? "127.0.0.1 (nur lokal)" : "0.0.0.0 (Netzwerk/VPN)";
logger.Info($"🌐 WebGUI aktiv auf Port {webOptions.Port} [{bind}].");
if (string.IsNullOrWhiteSpace(webOptions.AuthToken))
logger.Warning("⚠️ WebGUI: Kein Zugangs-Token gesetzt – Zugriff ist vollständig gesperrt.");
}
else
{
logger.Info("🌐 WebGUI deaktiviert (WebUi:Enabled = false).");
}
return Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
.ConfigureAppConfiguration((_, config) =>
@@ -242,6 +279,7 @@ namespace PolyTrader.App.Avalonia
.ConfigureServices((context, services) =>
{
services.AddSingleton(dcOptions);
services.AddSingleton(webOptions);
var databaseOptions = new DatabaseOptions
{
MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty
@@ -306,6 +344,7 @@ namespace PolyTrader.App.Avalonia
// Shell-Zustand (Fensterverwaltung). Auch im kopflosen Betrieb harmlos.
services.AddSingleton<AvaloniaUiHost>();
})
.ConfigurePolyTraderWeb(webOptions)
.Build();
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Configuration;
@@ -63,7 +63,8 @@ namespace PolyTrader.App.Avalonia.Shell
services.GetRequiredService<TerminalLogger>(),
services.GetService<WatchdogHeartbeatService>(),
services.GetService<MullvadVpnService>(),
services.GetService<PolyTrader.Core.Configuration.DeploymentcenterOptions>())
services.GetService<PolyTrader.Core.Configuration.DeploymentcenterOptions>(),
services.GetService<PolyTrader.Web.Configuration.WebUiOptions>())
});
host.RegisterView(new ModuleView
@@ -1,6 +1,7 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PolyTrader.App.Avalonia.Views.DialogWindow"
Icon="avares://PolyTrader.App.Avalonia/Assets/icon.png"
Width="520" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
CanResize="False"
@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:models="using:PolyTraderSharp.Models"
@@ -70,6 +70,64 @@
</DockPanel>
</TabItem>
<TabItem Header="WebUI">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Margin="24,20" MaxWidth="680" HorizontalAlignment="Left" Spacing="16">
<TextBlock Text="WebUI Konfiguration &amp; Fernzugriff" FontSize="18" FontWeight="SemiBold" />
<TextBlock Text="Das WebUI ermöglicht die Steuerung und Überwachung von PolyTrader über einen Browser – parallel zum Desktop GUI oder im kopflosen Server-Betrieb."
Foreground="{DynamicResource AppMutedTextBrush}" TextWrapping="Wrap" />
<Border Background="{DynamicResource AppSurfaceBrush}" BorderBrush="{DynamicResource AppBorderBrush}" BorderThickness="1" CornerRadius="8" Padding="16">
<StackPanel Spacing="12">
<TextBlock Text="Server-Status" FontWeight="SemiBold" FontSize="14" />
<TextBlock Name="lblWebUiStatus" Text="Status wird ermittelt …" Foreground="{DynamicResource AppAccentBrush}" />
<TextBlock Name="lblWebUiUrl" Text="http://127.0.0.1:5001" Foreground="{DynamicResource AppMutedTextBrush}" FontSize="12" />
</StackPanel>
</Border>
<Border Background="{DynamicResource AppSurfaceBrush}" BorderBrush="{DynamicResource AppBorderBrush}" BorderThickness="1" CornerRadius="8" Padding="16">
<StackPanel Spacing="16">
<TextBlock Text="Netzwerk &amp; Port" FontWeight="SemiBold" FontSize="14" />
<CheckBox Name="chkWebUiEnabled" Content="WebUI Server aktivieren" />
<Grid ColumnDefinitions="140,*" Margin="0,4">
<TextBlock Grid.Column="0" Text="Port:" VerticalAlignment="Center" />
<NumericUpDown Name="numWebUiPort" Grid.Column="1" Minimum="1024" Maximum="65535" Value="5001" Width="150" HorizontalAlignment="Left" />
</Grid>
<CheckBox Name="chkWebUiLocalOnly" Content="Nur lokaler Zugriff (127.0.0.1)" />
<TextBlock Text="Wenn deaktiviert, lauscht der Server auf 0.0.0.0 und ist über LAN/VPN erreichbar."
Foreground="{DynamicResource AppMutedTextBrush}" FontSize="12" Margin="24,-8,0,0" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Background="{DynamicResource AppSurfaceBrush}" BorderBrush="{DynamicResource AppBorderBrush}" BorderThickness="1" CornerRadius="8" Padding="16">
<StackPanel Spacing="12">
<TextBlock Text="Zugangssicherung (Token)" FontWeight="SemiBold" FontSize="14" />
<TextBlock Text="Ohne gültiges Token verweigert das WebUI jeden Zugriff. Das Token wird in appsettings.Local.json gespeichert."
Foreground="{DynamicResource AppMutedTextBrush}" FontSize="12" TextWrapping="Wrap" />
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,6">
<TextBox Name="txtWebUiToken" Grid.Column="0" PasswordChar="●" FontFamily="Consolas,monospace" FontSize="12" VerticalAlignment="Center" Margin="0,0,8,0" />
<Button Name="btnWebUiToggleShowToken" Grid.Column="1" Content="Anzeigen" Margin="0,0,8,0" />
<Button Name="btnWebUiCopyToken" Grid.Column="2" Content="Kopieren" />
</Grid>
<StackPanel Orientation="Horizontal" Spacing="10" Margin="0,4,0,0">
<Button Name="btnWebUiGenToken" Content="Neuen Token generieren" />
</StackPanel>
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" Spacing="12" Margin="0,8,0,0">
<Button Name="btnSaveWebUi" Content="WebUI-Einstellungen speichern" />
<TextBlock Name="lblWebUiSaveStatus" Text="" VerticalAlignment="Center" Foreground="{DynamicResource AppPositiveBrush}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</DockPanel>
@@ -15,6 +15,7 @@ using PolyTrader.Core.Security;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
using PolyTrader.Web.Configuration;
namespace PolyTrader.App.Avalonia.Views
{
@@ -36,12 +37,14 @@ namespace PolyTrader.App.Avalonia.Views
private MullvadVpnService? _vpn;
private TerminalLogger? _logger;
private DeploymentcenterOptions? _dcOptions;
private WebUiOptions _webOptions = new();
public SettingsWindow() => AvaloniaXamlLoader.Load(this);
public SettingsWindow(IModuleUiHost host, IAccountRepository accountRepo, TradingState state,
TerminalLogger logger, WatchdogHeartbeatService? watchdog = null,
MullvadVpnService? vpn = null, DeploymentcenterOptions? dcOptions = null) : this()
MullvadVpnService? vpn = null, DeploymentcenterOptions? dcOptions = null,
WebUiOptions? webOptions = null) : this()
{
_accountRepo = accountRepo;
_state = state;
@@ -49,6 +52,7 @@ namespace PolyTrader.App.Avalonia.Views
_watchdog = watchdog;
_vpn = vpn;
_dcOptions = dcOptions;
_webOptions = webOptions ?? Program.LoadWebUiOptions();
var grid = this.FindControl<DataGrid>("gridAccounts")!;
grid.ItemsSource = _accounts;
@@ -68,8 +72,14 @@ namespace PolyTrader.App.Avalonia.Views
this.FindControl<Button>("btnAccDelete")!.Click += async (_, _) => await DeleteAccountAsync();
this.FindControl<Button>("btnAccSave")!.Click += (_, _) => SaveSelectedAccount();
this.FindControl<Button>("btnWebUiGenToken")!.Click += (_, _) => GenerateWebUiToken();
this.FindControl<Button>("btnWebUiToggleShowToken")!.Click += (_, _) => ToggleWebUiTokenVisibility();
this.FindControl<Button>("btnWebUiCopyToken")!.Click += async (_, _) => await CopyWebUiTokenAsync();
this.FindControl<Button>("btnSaveWebUi")!.Click += (_, _) => SaveWebUiSettings();
ReloadServerSettings();
LoadAccounts();
LoadWebUiSettings();
UpdateMasterKeyButtonState();
}
@@ -422,5 +432,98 @@ namespace PolyTrader.App.Avalonia.Views
btn.IsEnabled = true;
}
}
// ===== WebUI-Einstellungen =====
private void LoadWebUiSettings()
{
_webOptions = Program.LoadWebUiOptions();
var chkEnabled = this.FindControl<CheckBox>("chkWebUiEnabled")!;
var numPort = this.FindControl<NumericUpDown>("numWebUiPort")!;
var chkLocal = this.FindControl<CheckBox>("chkWebUiLocalOnly")!;
var txtToken = this.FindControl<TextBox>("txtWebUiToken")!;
var lblStatus = this.FindControl<TextBlock>("lblWebUiStatus")!;
var lblUrl = this.FindControl<TextBlock>("lblWebUiUrl")!;
chkEnabled.IsChecked = _webOptions.Enabled;
numPort.Value = _webOptions.Port;
chkLocal.IsChecked = _webOptions.LocalOnly;
txtToken.Text = _webOptions.AuthToken ?? string.Empty;
if (_webOptions.Enabled)
{
string ip = _webOptions.LocalOnly ? "127.0.0.1" : "0.0.0.0";
lblStatus.Text = $"Aktiv (lauscht auf {ip}:{_webOptions.Port})";
lblUrl.Text = $"http://{(_webOptions.LocalOnly ? "localhost" : "0.0.0.0")}:{_webOptions.Port}";
}
else
{
lblStatus.Text = "Deaktiviert (WebUi:Enabled = false)";
lblUrl.Text = "—";
}
}
private void GenerateWebUiToken()
{
string newToken = WebUiConfigManager.GenerateSecureToken();
var txtToken = this.FindControl<TextBox>("txtWebUiToken")!;
txtToken.Text = newToken;
_webOptions.AuthToken = newToken;
WebUiConfigManager.SaveToLocalSettings(_webOptions);
_logger?.Info("🔐 WebUI: Neues Zugangs-Token im Einstellungsfenster generiert und gespeichert.");
SetWebUiSaveStatus("Neues Token generiert & gespeichert.");
}
private void ToggleWebUiTokenVisibility()
{
var txt = this.FindControl<TextBox>("txtWebUiToken")!;
var btn = this.FindControl<Button>("btnWebUiToggleShowToken")!;
if (txt.PasswordChar == '●')
{
txt.PasswordChar = '\0';
btn.Content = "Ausblenden";
}
else
{
txt.PasswordChar = '●';
btn.Content = "Anzeigen";
}
}
private async System.Threading.Tasks.Task CopyWebUiTokenAsync()
{
var txt = this.FindControl<TextBox>("txtWebUiToken")!;
if (string.IsNullOrEmpty(txt.Text)) return;
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.Clipboard != null)
{
await topLevel.Clipboard.SetTextAsync(txt.Text);
SetWebUiSaveStatus("Token in Zwischenablage kopiert.");
}
}
private void SaveWebUiSettings()
{
var chkEnabled = this.FindControl<CheckBox>("chkWebUiEnabled")!;
var numPort = this.FindControl<NumericUpDown>("numWebUiPort")!;
var chkLocal = this.FindControl<CheckBox>("chkWebUiLocalOnly")!;
var txtToken = this.FindControl<TextBox>("txtWebUiToken")!;
_webOptions.Enabled = chkEnabled.IsChecked ?? true;
_webOptions.Port = (int)(numPort.Value ?? 5001);
_webOptions.LocalOnly = chkLocal.IsChecked ?? true;
_webOptions.AuthToken = txtToken.Text?.Trim() ?? string.Empty;
WebUiConfigManager.SaveToLocalSettings(_webOptions);
_logger?.Info($"WebUI-Einstellungen in appsettings.Local.json gespeichert (Port {_webOptions.Port}, LocalOnly: {_webOptions.LocalOnly}).");
SetWebUiSaveStatus("Gespeichert in appsettings.Local.json (Port-/Netzwerkänderungen greifen nach Neustart).");
LoadWebUiSettings();
}
private void SetWebUiSaveStatus(string msg)
{
var lbl = this.FindControl<TextBlock>("lblWebUiSaveStatus")!;
lbl.Text = $"✓ {msg}";
}
}
}
@@ -4,6 +4,7 @@
x:Class="PolyTrader.App.Avalonia.Views.ShellWindow"
x:DataType="shell:ShellNavModel"
Title="PolyTrader"
Icon="avares://PolyTrader.App.Avalonia/Assets/icon.png"
Width="1600" Height="900"
WindowState="Maximized">
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PolyTrader.App.Avalonia.Views.ShutdownConfirmWindow"
Title="PolyTrader beenden"
Icon="avares://PolyTrader.App.Avalonia/Assets/icon.png"
Width="480" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
CanResize="False"
@@ -23,5 +23,12 @@
"Microsoft.EntityFrameworkCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"WebUi": {
"_comment": "Port (Standard 5001), LocalOnly (Standard true = nur 127.0.0.1). AuthToken liegt in appsettings.Local.json.",
"Enabled": true,
"Port": 5001,
"LocalOnly": true,
"AuthToken": ""
}
}
@@ -0,0 +1,72 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PolyTrader.Web.Configuration
{
/// <summary>
/// Liest, schreibt und aktualisiert die WebUI-Konfiguration in appsettings.Local.json
/// und generiert kryptographisch sichere Tokens.
/// </summary>
public static class WebUiConfigManager
{
public static string DefaultLocalSettingsPath =>
Path.Combine(AppContext.BaseDirectory, "appsettings.Local.json");
/// <summary>
/// Generiert ein kryptographisch sicheres 32-Byte Zufallstoken als Hex-String (64 Zeichen).
/// </summary>
public static string GenerateSecureToken()
{
byte[] bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
/// <summary>
/// Speichert die WebUi-Einstellungen in der angegebenen (oder Standard-) appsettings.Local.json.
/// Vorhandene Sektionen (Database, Deploymentcenter etc.) bleiben vollständig erhalten.
/// </summary>
public static void SaveToLocalSettings(WebUiOptions options, string? filePath = null)
{
string path = filePath ?? DefaultLocalSettingsPath;
JsonObject root;
if (File.Exists(path))
{
try
{
string content = File.ReadAllText(path);
root = JsonNode.Parse(content)?.AsObject() ?? new JsonObject();
}
catch
{
root = new JsonObject();
}
}
else
{
root = new JsonObject();
}
var webUiNode = new JsonObject
{
["Enabled"] = options.Enabled,
["Port"] = options.Port,
["LocalOnly"] = options.LocalOnly,
["AuthToken"] = options.AuthToken
};
root[WebUiOptions.SectionName] = webUiNode;
var jsonOptions = new JsonSerializerOptions
{
WriteIndented = true
};
string updatedJson = root.ToJsonString(jsonOptions);
File.WriteAllText(path, updatedJson);
}
}
}
@@ -0,0 +1,40 @@
using System;
namespace PolyTrader.Web.Configuration
{
/// <summary>
/// Konfiguration für das parallele WebGUI von PolyTrader.
/// </summary>
public class WebUiOptions
{
public const string SectionName = "WebUi";
/// <summary>
/// Gibt an, ob das WebGUI aktiv ist.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Port, auf dem Kestrel lauscht (Standard: 5001).
/// </summary>
public int Port { get; set; } = 5001;
/// <summary>
/// Wenn true, bindet Kestrel nur an Loopback (127.0.0.1 / localhost).
/// Wenn false, bindet Kestrel an 0.0.0.0 (alle Netzwerkschnittstellen, LAN/VPN).
/// </summary>
public bool LocalOnly { get; set; } = true;
/// <summary>
/// Der Zugangs-Token zur Sicherung des WebGUI.
/// Liegt aus Sicherheitsgründen üblicherweise in appsettings.Local.json.
/// Wenn leer, wird jeder Zugriff gesperrt.
/// </summary>
public string AuthToken { get; set; } = string.Empty;
/// <summary>
/// Gültigkeitsdauer eines Session-Cookies in Stunden.
/// </summary>
public int SessionTimeoutHours { get; set; } = 24;
}
}
@@ -0,0 +1,39 @@
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using PolyTraderSharp;
namespace PolyTrader.Web.Endpoints
{
public static class AccountsEndpoints
{
public static IEndpointRouteBuilder MapAccountsEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/accounts");
group.MapGet("/", (TradingState state) =>
{
var list = state.Accounts.Values
.OrderBy(a => a.AccountId)
.Select(a => new
{
accountId = a.AccountId,
name = a.Name,
isActive = a.IsActive,
isDemo = a.IsDemo,
closeOnlyMode = a.CloseOnlyMode,
usdcBalance = a.AvailableBalance,
portfolioValue = a.TotalBalance,
walletAddress = a.WalletAddress,
openPositionsCount = a.OpenPositions.Count
})
.ToList();
return Results.Ok(list);
});
return routes;
}
}
}
@@ -0,0 +1,71 @@
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using PolyTrader.Web.Configuration;
using PolyTrader.Web.Security;
namespace PolyTrader.Web.Endpoints
{
public static class AuthEndpoints
{
public record LoginRequest(string? Token);
public record TokenResponse(string Token);
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/auth");
group.MapGet("/status", (HttpContext context, IOptions<WebUiOptions> optionsProvider) =>
{
var options = optionsProvider.Value;
bool isConfigured = !string.IsNullOrWhiteSpace(options.AuthToken);
bool isAuthenticated = TokenAuthMiddleware.IsAuthenticated(context, options.AuthToken);
return Results.Ok(new
{
isConfigured,
isAuthenticated
});
});
group.MapPost("/login", (LoginRequest req, HttpContext context, IOptions<WebUiOptions> optionsProvider) =>
{
var options = optionsProvider.Value;
if (string.IsNullOrWhiteSpace(options.AuthToken))
{
return Results.BadRequest(new { error = "Kein Zugangs-Token im System konfiguriert." });
}
if (string.IsNullOrWhiteSpace(req.Token) || !TokenAuthMiddleware.ValidateToken(req.Token, options.AuthToken))
{
return Results.Json(new { error = "Ungültiges Zugangs-Token." }, statusCode: StatusCodes.Status401Unauthorized);
}
context.Response.Cookies.Append(TokenAuthMiddleware.CookieName, req.Token.Trim(), new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
Secure = context.Request.IsHttps,
Expires = DateTimeOffset.UtcNow.AddHours(options.SessionTimeoutHours),
Path = "/"
});
return Results.Ok(new { success = true });
});
group.MapPost("/logout", (HttpContext context) =>
{
context.Response.Cookies.Delete(TokenAuthMiddleware.CookieName, new CookieOptions
{
Path = "/"
});
return Results.Ok(new { success = true });
});
return routes;
}
}
}
@@ -0,0 +1,77 @@
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PolyTraderSharp;
using PolyTraderSharp.Services;
namespace PolyTrader.Web.Endpoints
{
public static class DashboardEndpoints
{
public record SetTradingModeRequest(string ModeType, string TargetMode);
public static IEndpointRouteBuilder MapDashboardEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/dashboard");
group.MapGet("/summary", (TradingState state, IServiceProvider sp) =>
{
var vpn = sp.GetService<MullvadVpnService>();
var watchdog = sp.GetService<WatchdogHeartbeatService>();
var accounts = state.Accounts.Values.ToList();
decimal totalAvailable = accounts.Sum(a => a.AvailableBalance);
decimal totalBalance = accounts.Sum(a => a.TotalBalance);
return Results.Ok(new
{
globalTradingPaused = state.GlobalTradingPaused,
liveTradingMode = state.LiveTradingMode.ToString(),
demoTradingMode = state.DemoTradingMode.ToString(),
alchemyHealthy = state.IsAlchemyHealthy,
globalPnl = state.GlobalPnl,
vpnConnected = vpn?.IsConnected ?? false,
vpnEnabled = vpn != null,
watchdogActive = watchdog != null,
accountCount = accounts.Count,
totalUsdcBalance = totalAvailable,
totalPortfolioValue = totalBalance
});
});
group.MapPost("/trading-mode", (SetTradingModeRequest req, TradingState state, TerminalLogger logger) =>
{
if (req.ModeType.Equals("live", System.StringComparison.OrdinalIgnoreCase))
{
if (System.Enum.TryParse<TradingMode>(req.TargetMode, true, out var parsed))
{
state.LiveTradingMode = parsed;
logger.Info($"🌐 WebUI: Live-Trading-Modus auf '{parsed}' geändert.");
return Results.Ok(new { success = true, currentMode = state.LiveTradingMode.ToString() });
}
}
else if (req.ModeType.Equals("demo", System.StringComparison.OrdinalIgnoreCase))
{
if (System.Enum.TryParse<TradingMode>(req.TargetMode, true, out var parsed))
{
state.DemoTradingMode = parsed;
logger.Info($"🌐 WebUI: Demo-Trading-Modus auf '{parsed}' geändert.");
return Results.Ok(new { success = true, currentMode = state.DemoTradingMode.ToString() });
}
}
else if (req.ModeType.Equals("pause", System.StringComparison.OrdinalIgnoreCase))
{
state.GlobalTradingPaused = !state.GlobalTradingPaused;
logger.Warning($"🌐 WebUI: GlobalTradingPaused auf '{state.GlobalTradingPaused}' umgeschaltet.");
return Results.Ok(new { success = true, isPaused = state.GlobalTradingPaused });
}
return Results.BadRequest(new { error = "Ungültiger Modus oder Parametertyp." });
});
return routes;
}
}
}
@@ -0,0 +1,63 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using PolyTraderSharp.Services;
namespace PolyTrader.Web.Endpoints
{
public static class JobsEndpoints
{
public static IEndpointRouteBuilder MapJobsEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/jobs");
group.MapGet("/", (JobManager jobManager) =>
{
var jobs = jobManager.Jobs.Select(j => new
{
jobName = j.JobName,
description = j.Description,
isEnabled = j.IsEnabled,
lastRun = j.LastRun,
nextRun = j.NextRun,
statusText = j.StatusText,
canTriggerManually = j.ManualTriggerAction != null
}).ToList();
return Results.Ok(jobs);
});
group.MapPost("/{jobName}/run", async (string jobName, JobManager jobManager, TerminalLogger logger) =>
{
var job = jobManager.Jobs.FirstOrDefault(j => j.JobName.Equals(jobName, StringComparison.OrdinalIgnoreCase));
if (job == null)
return Results.NotFound(new { error = $"Job '{jobName}' nicht gefunden." });
if (job.ManualTriggerAction == null)
return Results.BadRequest(new { error = $"Job '{jobName}' unterstützt keine manuelle Ausführung." });
try
{
logger.Info($"🌐 WebUI: Job '{job.JobName}' manuell angestoßen.");
// Nicht blockieren, sondern im Hintergrund ausführen
_ = Task.Run(async () =>
{
try { await job.ManualTriggerAction(); }
catch (Exception ex) { logger.Error($"Fehler bei manuellem Lauf von '{job.JobName}': {ex.Message}"); }
});
return Results.Ok(new { success = true, message = $"Job '{job.JobName}' gestartet." });
}
catch (Exception ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status500InternalServerError);
}
});
return routes;
}
}
}
@@ -0,0 +1,90 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using PolyTraderSharp.Services;
namespace PolyTrader.Web.Endpoints
{
public static class LogsEndpoints
{
public static IEndpointRouteBuilder MapLogsEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/logs");
group.MapGet("/", (TerminalLogger logger, int? minutes) =>
{
var span = TimeSpan.FromMinutes(minutes.GetValueOrDefault(60));
var history = logger.GetHistory(span)
.Select(e => new
{
time = e.Timestamp.ToString("HH:mm:ss"),
timestamp = e.Timestamp,
level = e.Level.ToString(),
cid = e.CorrelationId,
msg = e.Message
})
.ToList();
return Results.Ok(history);
});
group.MapGet("/stream", async (HttpContext context, TerminalLogger logger, CancellationToken ct) =>
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.Connection = "keep-alive";
var channel = Channel.CreateUnbounded<LogMessageEventArgs>(new UnboundedChannelOptions
{
SingleWriter = false,
SingleReader = true
});
void Handler(object? _, LogMessageEventArgs e)
{
channel.Writer.TryWrite(e);
}
logger.OnLogMessage += Handler;
try
{
await context.Response.WriteAsync(": connected\n\n", ct);
await context.Response.Body.FlushAsync(ct);
while (!ct.IsCancellationRequested)
{
var log = await channel.Reader.ReadAsync(ct);
var payload = JsonSerializer.Serialize(new
{
time = log.Timestamp.ToString("HH:mm:ss"),
level = log.Level.ToString(),
cid = log.CorrelationId,
msg = log.Message
});
await context.Response.WriteAsync($"data: {payload}\n\n", ct);
await context.Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException)
{
// Client hat Verbindung getrennt
}
finally
{
logger.OnLogMessage -= Handler;
channel.Writer.TryComplete();
}
});
return routes;
}
}
}
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Modularity;
namespace PolyTrader.Web.Endpoints
{
public static class ModulesEndpoints
{
public static IEndpointRouteBuilder MapModulesEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/modules");
group.MapGet("/", (IServiceProvider sp) =>
{
var activeModules = sp.GetServices<IPolyTraderModule>().ToList();
var activeNames = activeModules.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
var knownModules = new[]
{
"CopyTrading",
"ResolutionFarming",
"Supervisor",
"Accounting"
};
var list = knownModules.Select(name => new
{
name,
isActive = activeNames.Contains(name)
}).ToList();
return Results.Ok(list);
});
return routes;
}
}
}
@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using PolyTrader.Web.Configuration;
using PolyTraderSharp.Services;
namespace PolyTrader.Web.Endpoints
{
public static class SettingsEndpoints
{
public record UpdateWebUiSettingsRequest(bool Enabled, int Port, bool LocalOnly, string? AuthToken);
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/settings");
group.MapGet("/webui", (IOptions<WebUiOptions> optionsProvider) =>
{
var opt = optionsProvider.Value;
return Results.Ok(new
{
enabled = opt.Enabled,
port = opt.Port,
localOnly = opt.LocalOnly,
hasToken = !string.IsNullOrWhiteSpace(opt.AuthToken),
token = opt.AuthToken
});
});
group.MapPost("/webui", (UpdateWebUiSettingsRequest req, IOptions<WebUiOptions> optionsProvider, TerminalLogger logger) =>
{
var opt = optionsProvider.Value;
opt.Enabled = req.Enabled;
opt.Port = req.Port > 0 && req.Port <= 65535 ? req.Port : 5001;
opt.LocalOnly = req.LocalOnly;
if (req.AuthToken != null)
{
opt.AuthToken = req.AuthToken.Trim();
}
WebUiConfigManager.SaveToLocalSettings(opt);
logger.Info($"🌐 WebUI: Einstellungen gespeichert (Port {opt.Port}, LocalOnly: {opt.LocalOnly}).");
return Results.Ok(new
{
success = true,
enabled = opt.Enabled,
port = opt.Port,
localOnly = opt.LocalOnly,
hasToken = !string.IsNullOrWhiteSpace(opt.AuthToken)
});
});
group.MapPost("/webui/generate-token", (IOptions<WebUiOptions> optionsProvider, TerminalLogger logger) =>
{
var opt = optionsProvider.Value;
string newToken = WebUiConfigManager.GenerateSecureToken();
opt.AuthToken = newToken;
WebUiConfigManager.SaveToLocalSettings(opt);
logger.Info("🔐 WebUI: Neues Zugangs-Token generiert und in appsettings.Local.json gespeichert.");
return Results.Ok(new
{
success = true,
token = newToken
});
});
return routes;
}
}
}
@@ -0,0 +1,85 @@
using System;
using System.IO;
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using PolyTrader.Web.Configuration;
using PolyTrader.Web.Endpoints;
using PolyTrader.Web.Security;
namespace PolyTrader.Web.Hosting
{
public static class WebUiHostingExtensions
{
public static IHostBuilder ConfigurePolyTraderWeb(this IHostBuilder hostBuilder, WebUiOptions options)
{
if (!options.Enabled)
return hostBuilder;
hostBuilder.ConfigureServices((_, services) =>
{
services.AddSingleton(Options.Create(options));
});
hostBuilder.ConfigureWebHostDefaults(webBuilder =>
{
// Bestimme den wwwroot-Pfad zuverlässig
string webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
if (!Directory.Exists(webRoot))
{
string candidate = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "src", "PolyTrader.Web", "wwwroot");
if (Directory.Exists(candidate))
webRoot = Path.GetFullPath(candidate);
}
webBuilder.UseWebRoot(webRoot);
webBuilder.ConfigureKestrel(kestrel =>
{
var ip = options.LocalOnly ? IPAddress.Loopback : IPAddress.Any;
kestrel.Listen(ip, options.Port);
});
webBuilder.Configure((context, app) =>
{
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseMiddleware<TokenAuthMiddleware>();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapAuthEndpoints();
endpoints.MapDashboardEndpoints();
endpoints.MapAccountsEndpoints();
endpoints.MapJobsEndpoints();
endpoints.MapLogsEndpoints();
endpoints.MapSettingsEndpoints();
endpoints.MapModulesEndpoints();
endpoints.MapGet("/login", async httpContext =>
{
var env = httpContext.RequestServices.GetRequiredService<IWebHostEnvironment>();
string file = Path.Combine(env.WebRootPath, "login.html");
if (!File.Exists(file))
file = Path.Combine(AppContext.BaseDirectory, "wwwroot", "login.html");
httpContext.Response.ContentType = "text/html; charset=utf-8";
await httpContext.Response.SendFileAsync(file);
});
endpoints.MapFallbackToFile("index.html");
});
});
});
return hostBuilder;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>PolyTrader.Web</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PolyTrader.Core\PolyTrader.Core.csproj" />
<ProjectReference Include="..\PolyTrader.Modules.CopyTrading\PolyTrader.Modules.CopyTrading.csproj" />
<ProjectReference Include="..\PolyTrader.Modules.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.csproj" />
<ProjectReference Include="..\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj" />
<ProjectReference Include="..\PolyTrader.Modules.Accounting\PolyTrader.Modules.Accounting.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="wwwroot\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<!-- Erlaubt dem Testprojekt, interne Typen zu testen. -->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>PolyTrader.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
@@ -0,0 +1,128 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using PolyTrader.Web.Configuration;
namespace PolyTrader.Web.Security
{
/// <summary>
/// Sichert alle Endpunkte und Seiten des WebGUI ab.
/// Erzwingt bei unauthentifiziertem Zugriff sofort einen Redirect auf /login
/// bzw. 401 Unauthorized bei API-Aufrufen.
/// </summary>
public class TokenAuthMiddleware
{
public const string CookieName = "polytrader_web_auth";
public const string TokenHeaderName = "X-PolyTrader-Token";
private readonly RequestDelegate _next;
public TokenAuthMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, IOptions<WebUiOptions> optionsProvider)
{
var options = optionsProvider.Value;
var path = context.Request.Path.Value ?? "/";
// Öffentlich zugängliche Pfade (Login-Seite und deren Assets)
if (IsPublicPath(path))
{
await _next(context);
return;
}
// Wenn überhaupt kein Token konfiguriert ist: Vollständige Sperre
if (string.IsNullOrWhiteSpace(options.AuthToken))
{
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync("{\"error\":\"Kein WebUI-Zugangstoken konfiguriert. Bitte in den Einstellungen generieren.\"}");
return;
}
context.Response.Redirect("/login?error=no_token");
return;
}
// Authentifizierung prüfen (Cookie oder Header)
if (IsAuthenticated(context, options.AuthToken))
{
await _next(context);
return;
}
// Unautorisierter Zugriff
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync("{\"error\":\"Nicht autorisiert. Ungültiges oder fehlendes Token.\"}");
return;
}
// HTML-Aufruf: Sofortiger Redirect zur Login-Maske
context.Response.Redirect("/login");
}
private static bool IsPublicPath(string path)
{
return path.Equals("/login", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/login.html", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/api/auth/login", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/api/auth/status", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/css/", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/js/login", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/favicon.ico", StringComparison.OrdinalIgnoreCase);
}
public static bool IsAuthenticated(HttpContext context, string configuredToken)
{
if (string.IsNullOrWhiteSpace(configuredToken))
return false;
// 1. Aus Cookie lesen
if (context.Request.Cookies.TryGetValue(CookieName, out var cookieToken)
&& ValidateToken(cookieToken, configuredToken))
{
return true;
}
// 2. Aus Authorization Header (Bearer) lesen
var authHeader = context.Request.Headers.Authorization.ToString();
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var bearerToken = authHeader.Substring(7).Trim();
if (ValidateToken(bearerToken, configuredToken))
return true;
}
// 3. Aus X-PolyTrader-Token Header lesen
if (context.Request.Headers.TryGetValue(TokenHeaderName, out var customHeaderToken)
&& ValidateToken(customHeaderToken.ToString(), configuredToken))
{
return true;
}
return false;
}
public static bool ValidateToken(string candidate, string expected)
{
if (string.IsNullOrEmpty(candidate) || string.IsNullOrEmpty(expected))
return false;
byte[] candidateBytes = Encoding.UTF8.GetBytes(candidate.Trim());
byte[] expectedBytes = Encoding.UTF8.GetBytes(expected.Trim());
return CryptographicOperations.FixedTimeEquals(candidateBytes, expectedBytes);
}
}
}
+367
View File
@@ -0,0 +1,367 @@
:root {
--bg: #181825;
--surface: #1e1e2e;
--surface-alt: #252538;
--card: #2a2b3f;
--border: #313244;
--divider: #45475a;
--text: #cdd6f4;
--muted: #a6adc8;
--accent: #89b4fa;
--accent-soft: rgba(137, 180, 250, 0.15);
--positive: #a6e3a1;
--negative: #f38ba8;
--warning: #f9e2af;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Layout */
.app-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 20px;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.header-brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 16px;
}
.header-nav {
display: flex;
gap: 4px;
}
.nav-link {
color: var(--muted);
text-decoration: none;
padding: 8px 14px;
border-radius: 6px;
font-weight: 500;
transition: all 0.2s;
cursor: pointer;
background: transparent;
border: none;
font-size: 14px;
}
.nav-link:hover {
color: var(--text);
background: var(--surface-alt);
}
.nav-link.active {
color: var(--accent);
background: var(--accent-soft);
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
padding: 4px 8px;
background: var(--surface-alt);
border-radius: 20px;
border: 1px solid var(--border);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.dot.online { background: var(--positive); box-shadow: 0 0 6px var(--positive); }
.dot.offline { background: var(--negative); }
.app-main {
flex: 1;
padding: 24px 20px;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
/* Tabs / Views */
.view-panel {
display: none;
}
.view-panel.active {
display: block;
}
/* Cards & Grid */
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--muted);
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-value {
font-size: 26px;
font-weight: 700;
color: var(--text);
}
.card-sub {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
/* Controls / Toggles */
.toggle-group {
display: flex;
gap: 8px;
margin-top: 12px;
}
.btn-toggle {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-alt);
color: var(--muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-toggle:hover {
border-color: var(--divider);
color: var(--text);
}
.btn-toggle.active-green {
background: rgba(166, 227, 161, 0.2);
border-color: var(--positive);
color: var(--positive);
}
.btn-toggle.active-yellow {
background: rgba(249, 226, 175, 0.2);
border-color: var(--warning);
color: var(--warning);
}
.btn-toggle.active-red {
background: rgba(243, 139, 168, 0.2);
border-color: var(--negative);
color: var(--negative);
}
/* Buttons */
.btn {
padding: 8px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: #11111b;
}
.btn-primary:hover { opacity: 0.9; }
.btn-secondary {
background: var(--surface-alt);
border-color: var(--border);
color: var(--text);
}
.btn-secondary:hover { background: var(--card); }
.btn-danger {
background: rgba(243, 139, 168, 0.15);
border-color: var(--negative);
color: var(--negative);
}
.btn-danger:hover { background: rgba(243, 139, 168, 0.25); }
/* Data Tables */
.table-container {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow-x: auto;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 13px;
}
th {
background: var(--surface-alt);
color: var(--muted);
font-weight: 600;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
color: var(--text);
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
/* Terminal / Log Viewer */
.terminal-window {
background: #11111b;
border: 1px solid var(--border);
border-radius: 10px;
display: flex;
flex-direction: column;
height: calc(100vh - 210px);
min-height: 400px;
}
.terminal-toolbar {
padding: 10px 14px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.terminal-logs {
flex: 1;
overflow-y: auto;
padding: 12px 14px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
}
.log-entry {
white-space: pre-wrap;
word-break: break-all;
display: flex;
gap: 8px;
}
.log-time { color: var(--muted); }
.log-level { font-weight: 700; width: 65px; text-transform: uppercase; }
.log-level.Info { color: var(--accent); }
.log-level.Warning { color: var(--warning); }
.log-level.Error { color: var(--negative); }
.log-level.Trade { color: var(--positive); }
.log-level.Debug { color: #6c7086; }
.log-msg { color: var(--text); flex: 1; }
/* Settings Form */
.settings-form {
max-width: 600px;
}
.form-row {
margin-bottom: 20px;
}
.form-row label {
display: block;
font-weight: 600;
margin-bottom: 6px;
color: var(--text);
}
.form-desc {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
.form-input {
width: 100%;
padding: 10px 12px;
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 14px;
}
.form-input:focus {
border-color: var(--accent);
outline: none;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
+225
View File
@@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PolyTrader Web</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<header class="app-header">
<div class="header-brand">
<span>📈</span>
<span>PolyTrader</span>
<span style="font-size: 11px; padding: 2px 6px; background: var(--accent-soft); color: var(--accent); border-radius: 4px; font-weight: normal;">WebGUI</span>
</div>
<nav class="header-nav">
<button class="nav-link active" onclick="switchView('dashboard')">Dashboard</button>
<button class="nav-link" onclick="switchView('accounts')">Konten</button>
<button class="nav-link" onclick="switchView('jobs')">Jobs</button>
<button class="nav-link" onclick="switchView('logs')">Terminal / Logs</button>
<button class="nav-link" onclick="switchView('modules')">Module</button>
<button class="nav-link" onclick="switchView('settings')">Einstellungen</button>
</nav>
<div class="header-actions">
<div class="status-indicator" title="Polymarket / Alchemy Connection">
<span class="dot" id="dotAlchemy"></span>
<span>WSS</span>
</div>
<div class="status-indicator" title="Mullvad VPN Status">
<span class="dot" id="dotVpn"></span>
<span>VPN</span>
</div>
<div class="status-indicator" title="Watchdog Heartbeat">
<span class="dot" id="dotWatchdog"></span>
<span>Watchdog</span>
</div>
<button class="btn btn-secondary" style="padding: 4px 10px; font-size: 12px;" onclick="logout()">Abmelden</button>
</div>
</header>
<main class="app-main">
<!-- VIEW: DASHBOARD -->
<section id="view-dashboard" class="view-panel active">
<div class="grid-4">
<div class="card">
<div class="card-title">Gesamt PnL</div>
<div class="card-value" id="valGlobalPnl">0.00 $</div>
<div class="card-sub">Über alle aktiven Module</div>
</div>
<div class="card">
<div class="card-title">USDC Guthaben</div>
<div class="card-value" id="valTotalUsdc">0.00 $</div>
<div class="card-sub" id="valAccountCount">0 Konten aktiv</div>
</div>
<div class="card">
<div class="card-title">Portfolio-Gesamtwert</div>
<div class="card-value" id="valPortfolioValue">0.00 $</div>
<div class="card-sub">Guthaben + Positionen</div>
</div>
<div class="card">
<div class="card-title">Notfall-Pause</div>
<button class="btn btn-danger" id="btnPause" style="width: 100%; margin-top: 6px;" onclick="togglePause()">
Trading pausieren
</button>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-title">Live Trading Modus</div>
<div style="font-size: 13px; color: var(--muted);">Echtgeld-Ausführung auf Polymarket</div>
<div class="toggle-group" id="groupLiveTrading">
<button class="btn-toggle" onclick="setTradingMode('live', 'Inactive')">Inaktiv</button>
<button class="btn-toggle" onclick="setTradingMode('live', 'SellOnly')">Nur Verkauf</button>
<button class="btn-toggle" onclick="setTradingMode('live', 'Active')">Aktiv</button>
</div>
</div>
<div class="card">
<div class="card-title">Demo Trading Modus</div>
<div style="font-size: 13px; color: var(--muted);">Papertrading und Simulation</div>
<div class="toggle-group" id="groupDemoTrading">
<button class="btn-toggle" onclick="setTradingMode('demo', 'Inactive')">Inaktiv</button>
<button class="btn-toggle" onclick="setTradingMode('demo', 'SellOnly')">Nur Verkauf</button>
<button class="btn-toggle" onclick="setTradingMode('demo', 'Active')">Aktiv</button>
</div>
</div>
</div>
</section>
<!-- VIEW: ACCOUNTS -->
<section id="view-accounts" class="view-panel">
<h2 style="margin-bottom: 16px; font-size: 18px;">Polymarket Konten</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Status</th>
<th>Modus</th>
<th>USDC Balance</th>
<th>Portfolio-Wert</th>
<th>Proxy Wallet</th>
<th>Zuletzt aktualisiert</th>
</tr>
</thead>
<tbody id="tblAccounts">
<tr><td colspan="8" style="text-align: center; color: var(--muted);">Lade Konten …</td></tr>
</tbody>
</table>
</div>
</section>
<!-- VIEW: JOBS -->
<section id="view-jobs" class="view-panel">
<h2 style="margin-bottom: 16px; font-size: 18px;">Server Jobs & Hintergrund-Dienste</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Job Name</th>
<th>Beschreibung</th>
<th>Aktiv</th>
<th>Letzter Lauf</th>
<th>Nächster Lauf</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody id="tblJobs">
<tr><td colspan="7" style="text-align: center; color: var(--muted);">Lade Jobs …</td></tr>
</tbody>
</table>
</div>
</section>
<!-- VIEW: TERMINAL / LOGS -->
<section id="view-logs" class="view-panel">
<div class="terminal-window">
<div class="terminal-toolbar">
<div style="display: flex; gap: 10px; align-items: center;">
<span style="font-weight: 600;">Echtzeit-Log Stream</span>
<div class="status-indicator" style="padding: 2px 6px; font-size: 11px;">
<span class="dot online" id="sseDot"></span>
<span id="sseStatus">Verbunden</span>
</div>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<select id="logLevelFilter" class="form-input" style="padding: 4px 8px; font-size: 12px; width: auto;" onchange="applyLogFilter()">
<option value="ALL">Alle Level</option>
<option value="INFO">Info</option>
<option value="WARNING">Warning</option>
<option value="ERROR">Error</option>
<option value="TRADE">Trade</option>
</select>
<label class="checkbox-label" style="font-size: 12px; color: var(--muted);">
<input type="checkbox" id="chkAutoScroll" checked> Auto-Scroll
</label>
<button class="btn btn-secondary" style="padding: 4px 8px; font-size: 12px;" onclick="clearLogs()">Leeren</button>
</div>
</div>
<div class="terminal-logs" id="logContainer"></div>
</div>
</section>
<!-- VIEW: MODULES -->
<section id="view-modules" class="view-panel">
<h2 style="margin-bottom: 16px; font-size: 18px;">Strategie- & Analyse-Module</h2>
<div class="grid-2" id="modulesContainer">
<div class="card"><div class="card-title">Lade Module …</div></div>
</div>
</section>
<!-- VIEW: SETTINGS -->
<section id="view-settings" class="view-panel">
<h2 style="margin-bottom: 16px; font-size: 18px;">WebGUI Anwendungseinstellungen</h2>
<div class="card settings-form">
<form id="settingsForm" onsubmit="saveSettings(event)">
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="cfgEnabled">
WebGUI Server aktiv
</label>
<div class="form-desc">Steuert, ob Kestrel beim Programmstart hochfährt.</div>
</div>
<div class="form-row">
<label for="cfgPort">Server-Port</label>
<input type="number" id="cfgPort" class="form-input" min="1024" max="65535" required>
<div class="form-desc">Standard ist 5001. Änderung wird nach dem nächsten Neustart aktiv.</div>
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="cfgLocalOnly">
Nur lokaler Zugriff (127.0.0.1)
</label>
<div class="form-desc">Wenn deaktiviert, ist das WebUI über alle Netzwerkschnittstellen (LAN/VPN) erreichbar.</div>
</div>
<div class="form-row">
<label for="cfgToken">Zugangs-Token</label>
<div style="display: flex; gap: 8px;">
<input type="text" id="cfgToken" class="form-input" style="font-family: var(--font-mono); font-size: 12px;" readonly>
<button type="button" class="btn btn-secondary" onclick="copyToken()">Kopieren</button>
<button type="button" class="btn btn-secondary" onclick="generateNewToken()">Neu generieren</button>
</div>
<div class="form-desc">Wird in appsettings.Local.json gespeichert. Bei leerem Token ist das WebUI gesperrt.</div>
</div>
<div style="display: flex; gap: 10px; align-items: center; margin-top: 24px;">
<button type="submit" class="btn btn-primary" id="btnSaveSettings">Einstellungen speichern</button>
<span id="settingsStatusMsg" style="font-size: 13px; color: var(--positive); display: none;">✓ Gespeichert</span>
</div>
</form>
</div>
</section>
</main>
<script src="/js/app.js"></script>
</body>
</html>
+359
View File
@@ -0,0 +1,359 @@
// PolyTrader Web — Client-Side Application Logic
let currentView = 'dashboard';
let sseSource = null;
let allLogs = [];
// Init on DOM ready
document.addEventListener('DOMContentLoaded', () => {
initSseLogs();
refreshDashboard();
setInterval(refreshDashboard, 2500);
});
// Navigation
function switchView(viewName) {
currentView = viewName;
document.querySelectorAll('.nav-link').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.view-panel').forEach(p => p.classList.remove('active'));
const activeBtn = Array.from(document.querySelectorAll('.nav-link')).find(b => b.getAttribute('onclick')?.includes(viewName));
if (activeBtn) activeBtn.classList.add('active');
const panel = document.getElementById(`view-${viewName}`);
if (panel) panel.classList.add('active');
if (viewName === 'accounts') loadAccounts();
if (viewName === 'jobs') loadJobs();
if (viewName === 'modules') loadModules();
if (viewName === 'settings') loadSettings();
if (viewName === 'logs') {
const container = document.getElementById('logContainer');
if (document.getElementById('chkAutoScroll').checked) {
container.scrollTop = container.scrollHeight;
}
}
}
// Dashboard Refresh
async function refreshDashboard() {
try {
const res = await fetch('/api/dashboard/summary');
if (res.status === 401) {
window.location.href = '/login';
return;
}
if (!res.ok) return;
const data = await res.json();
// Status indicators
document.getElementById('dotAlchemy').className = data.alchemyHealthy ? 'dot online' : 'dot offline';
document.getElementById('dotVpn').className = data.vpnConnected ? 'dot online' : 'dot offline';
document.getElementById('dotWatchdog').className = data.watchdogActive ? 'dot online' : 'dot offline';
// KPIs
const pnlEl = document.getElementById('valGlobalPnl');
pnlEl.textContent = formatCurrency(data.globalPnl);
pnlEl.style.color = data.globalPnl >= 0 ? 'var(--positive)' : 'var(--negative)';
document.getElementById('valTotalUsdc').textContent = formatCurrency(data.totalUsdcBalance);
document.getElementById('valAccountCount').textContent = `${data.accountCount} Konten konfiguriert`;
document.getElementById('valPortfolioValue').textContent = formatCurrency(data.totalPortfolioValue);
// Pause Button
const pauseBtn = document.getElementById('btnPause');
if (data.globalTradingPaused) {
pauseBtn.textContent = '▶ Trading fortsetzen';
pauseBtn.className = 'btn btn-primary';
pauseBtn.style.background = 'var(--positive)';
} else {
pauseBtn.textContent = '⏸ Trading pausieren';
pauseBtn.className = 'btn btn-danger';
}
// Toggles
updateToggleGroup('groupLiveTrading', data.liveTradingMode);
updateToggleGroup('groupDemoTrading', data.demoTradingMode);
} catch (e) {
// Fehler bei Verbindung
}
}
function updateToggleGroup(groupId, currentMode) {
const group = document.getElementById(groupId);
if (!group) return;
const buttons = group.querySelectorAll('.btn-toggle');
buttons.forEach(btn => {
btn.className = 'btn-toggle';
const mode = btn.getAttribute('onclick')?.match(/'([^']+)'\)$/)?.[1];
if (mode && mode.toLowerCase() === currentMode.toLowerCase()) {
if (mode === 'Active') btn.classList.add('active-green');
else if (mode === 'SellOnly') btn.classList.add('active-yellow');
else btn.classList.add('active-red');
}
});
}
async function setTradingMode(type, mode) {
try {
const res = await fetch('/api/dashboard/trading-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modeType: type, targetMode: mode })
});
if (res.ok) refreshDashboard();
} catch (e) {}
}
async function togglePause() {
try {
const res = await fetch('/api/dashboard/trading-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modeType: 'pause', targetMode: '' })
});
if (res.ok) refreshDashboard();
} catch (e) {}
}
// Accounts View
async function loadAccounts() {
try {
const res = await fetch('/api/accounts');
if (!res.ok) return;
const accounts = await res.json();
const tbody = document.getElementById('tblAccounts');
if (accounts.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; color: var(--muted);">Keine Konten gefunden.</td></tr>';
return;
}
tbody.innerHTML = accounts.map(a => `
<tr>
<td><b>${a.accountId}</b></td>
<td>${escapeHtml(a.name)}</td>
<td><span style="color: ${a.isActive ? 'var(--positive)' : 'var(--muted)'};">●</span> ${a.isActive ? 'Aktiv' : 'Inaktiv'}</td>
<td>${a.isDemo ? '<span style="color: var(--warning);">Demo</span>' : '<span style="color: var(--positive);">Live</span>'}</td>
<td>${formatCurrency(a.usdcBalance)}</td>
<td>${formatCurrency(a.portfolioValue)}</td>
<td style="font-family: var(--font-mono); font-size: 11px; color: var(--muted);">${shortenAddress(a.proxyWalletAddress)}</td>
<td style="color: var(--muted); font-size: 11px;">${a.lastPolledAt ? new Date(a.lastPolledAt).toLocaleTimeString() : '—'}</td>
</tr>
`).join('');
} catch (e) {}
}
// Jobs View
async function loadJobs() {
try {
const res = await fetch('/api/jobs');
if (!res.ok) return;
const jobs = await res.json();
const tbody = document.getElementById('tblJobs');
if (jobs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; color: var(--muted);">Keine Jobs registriert.</td></tr>';
return;
}
tbody.innerHTML = jobs.map(j => `
<tr>
<td><b>${escapeHtml(j.jobName)}</b></td>
<td style="color: var(--muted); font-size: 12px;">${escapeHtml(j.description)}</td>
<td><span style="color: ${j.isEnabled ? 'var(--positive)' : 'var(--muted)'};">●</span> ${j.isEnabled ? 'Ja' : 'Nein'}</td>
<td style="font-size: 12px; color: var(--muted);">${j.lastRun ? new Date(j.lastRun).toLocaleTimeString() : '—'}</td>
<td style="font-size: 12px; color: var(--muted);">${j.nextRun ? new Date(j.nextRun).toLocaleTimeString() : '—'}</td>
<td><span style="padding: 2px 6px; background: var(--surface-alt); border-radius: 4px; font-size: 11px;">${escapeHtml(j.statusText)}</span></td>
<td>
${j.canTriggerManually ? `<button class="btn btn-secondary" style="padding: 3px 8px; font-size: 11px;" onclick="triggerJob('${escapeHtml(j.jobName)}')">Ausführen</button>` : ''}
</td>
</tr>
`).join('');
} catch (e) {}
}
async function triggerJob(jobName) {
try {
await fetch(`/api/jobs/${encodeURIComponent(jobName)}/run`, { method: 'POST' });
setTimeout(loadJobs, 500);
} catch (e) {}
}
// Modules View
async function loadModules() {
try {
const res = await fetch('/api/modules');
if (!res.ok) return;
const modules = await res.json();
const container = document.getElementById('modulesContainer');
container.innerHTML = modules.map(m => `
<div class="card">
<div class="card-title">
<span>${escapeHtml(m.name)}</span>
<span style="font-size: 12px; color: ${m.isActive ? 'var(--positive)' : 'var(--muted)'}; font-weight: normal;">
● ${m.isActive ? 'Aktiv im Betrieb' : 'Deaktiviert'}
</span>
</div>
<div style="font-size: 13px; color: var(--muted);">
${m.isActive ? 'Modul läuft planmäßig im Hintergrund.' : 'Nicht geladen (in den Servereinstellungen oder über DisabledModules deaktiviert).'}
</div>
</div>
`).join('');
} catch (e) {}
}
// Terminal & SSE Logs
function initSseLogs() {
const sseDot = document.getElementById('sseDot');
const sseStatus = document.getElementById('sseStatus');
if (sseSource) sseSource.close();
sseSource = new EventSource('/api/logs/stream');
sseSource.onopen = () => {
sseDot.className = 'dot online';
sseStatus.textContent = 'Verbunden';
};
sseSource.onmessage = (event) => {
if (!event.data || event.data.startsWith(':')) return;
try {
const entry = JSON.parse(event.data);
appendLog(entry);
} catch (e) {}
};
sseSource.onerror = () => {
sseDot.className = 'dot offline';
sseStatus.textContent = 'Verbindung getrennt (Reconnect …)';
};
}
function appendLog(entry) {
allLogs.push(entry);
if (allLogs.length > 2000) allLogs.shift();
const filter = document.getElementById('logLevelFilter').value;
if (filter !== 'ALL' && entry.level.toUpperCase() !== filter) return;
renderLogEntry(entry);
}
function renderLogEntry(entry) {
const container = document.getElementById('logContainer');
const div = document.createElement('div');
div.className = 'log-entry';
div.innerHTML = `
<span class="log-time">[${entry.time}]</span>
<span class="log-level ${entry.level}">${entry.level}</span>
<span class="log-msg">${escapeHtml(entry.msg)}</span>
`;
container.appendChild(div);
if (document.getElementById('chkAutoScroll').checked && currentView === 'logs') {
container.scrollTop = container.scrollHeight;
}
}
function applyLogFilter() {
const container = document.getElementById('logContainer');
container.innerHTML = '';
const filter = document.getElementById('logLevelFilter').value;
allLogs.forEach(entry => {
if (filter === 'ALL' || entry.level.toUpperCase() === filter) {
renderLogEntry(entry);
}
});
}
function clearLogs() {
allLogs = [];
document.getElementById('logContainer').innerHTML = '';
}
// Settings View
async function loadSettings() {
try {
const res = await fetch('/api/settings/webui');
if (!res.ok) return;
const opt = await res.json();
document.getElementById('cfgEnabled').checked = opt.enabled;
document.getElementById('cfgPort').value = opt.port;
document.getElementById('cfgLocalOnly').checked = opt.localOnly;
document.getElementById('cfgToken').value = opt.token || '';
} catch (e) {}
}
async function saveSettings(e) {
e.preventDefault();
const enabled = document.getElementById('cfgEnabled').checked;
const port = parseInt(document.getElementById('cfgPort').value, 10);
const localOnly = document.getElementById('cfgLocalOnly').checked;
const token = document.getElementById('cfgToken').value.trim();
try {
const res = await fetch('/api/settings/webui', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled, port, localOnly, authToken: token })
});
if (res.ok) {
const msg = document.getElementById('settingsStatusMsg');
msg.style.display = 'inline';
setTimeout(() => { msg.style.display = 'none'; }, 3000);
}
} catch (e) {}
}
async function generateNewToken() {
if (!confirm('Neues Zugangs-Token generieren? Alle bestehenden Sessions müssen sich danach neu anmelden.')) return;
try {
const res = await fetch('/api/settings/webui/generate-token', { method: 'POST' });
if (res.ok) {
const data = await res.json();
document.getElementById('cfgToken').value = data.token;
alert('Neues Token generiert und in appsettings.Local.json gespeichert:\n\n' + data.token);
}
} catch (e) {}
}
function copyToken() {
const input = document.getElementById('cfgToken');
if (!input.value) return;
navigator.clipboard.writeText(input.value).then(() => {
alert('Token in Zwischenablage kopiert!');
});
}
// Auth
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} finally {
window.location.href = '/login';
}
}
// Helpers
function formatCurrency(val) {
if (val === undefined || val === null) return '0.00 $';
return Number(val).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' $';
}
function shortenAddress(addr) {
if (!addr) return '—';
if (addr.length <= 12) return addr;
return addr.substring(0, 6) + '…' + addr.substring(addr.length - 4);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+197
View File
@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PolyTrader — WebUI Login</title>
<link rel="stylesheet" href="/css/app.css">
<style>
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background-color: var(--bg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text);
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px 28px;
width: 100%;
max-width: 420px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-icon {
font-size: 28px;
}
.brand-title {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
}
.brand-sub {
font-size: 13px;
color: var(--muted);
margin-top: 2px;
}
.alert {
padding: 12px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 20px;
display: none;
line-height: 1.4;
}
.alert-error {
background: rgba(243, 139, 168, 0.15);
border: 1px solid var(--negative);
color: var(--negative);
}
.alert-warning {
background: rgba(249, 226, 175, 0.15);
border: 1px solid var(--warning);
color: var(--warning);
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--muted);
margin-bottom: 8px;
}
.input-group {
position: relative;
display: flex;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 12px 14px;
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 14px;
outline: none;
box-sizing: border-box;
transition: border-color 0.2s;
}
input:focus {
border-color: var(--accent);
}
button.btn-primary {
width: 100%;
padding: 12px;
background: var(--accent);
color: #11111b;
font-weight: 600;
font-size: 14px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.2s;
}
button.btn-primary:hover {
opacity: 0.9;
}
button.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.footer-note {
margin-top: 20px;
font-size: 12px;
color: var(--muted);
text-align: center;
line-height: 1.4;
}
</style>
</head>
<body>
<div class="login-card">
<div class="brand">
<div class="brand-icon">📈</div>
<div>
<div class="brand-title">PolyTrader Web</div>
<div class="brand-sub">Zugangssicherung & Steuerung</div>
</div>
</div>
<div id="alertBox" class="alert"></div>
<form id="loginForm" onsubmit="handleLogin(event)">
<div class="form-group">
<label for="tokenInput">Zugangs-Token</label>
<div class="input-group">
<input type="password" id="tokenInput" placeholder="Token eingeben oder einfügen …" autocomplete="current-password" autofocus required>
</div>
</div>
<button type="submit" id="submitBtn" class="btn-primary">Anmelden</button>
</form>
<div class="footer-note">
Das Token ist in <code style="color:var(--accent);">appsettings.Local.json</code> hinterlegt und kann im Desktop-GUI unter <b>Einstellungen → WebUI</b> angezeigt und neu generiert werden.
</div>
</div>
<script>
// URL-Parameter prüfen
const urlParams = new URLSearchParams(window.location.search);
const alertBox = document.getElementById('alertBox');
if (urlParams.get('error') === 'no_token') {
alertBox.textContent = '⚠️ Es ist kein Zugangs-Token im System hinterlegt. Bitte generiere zuerst ein Token in den Desktop-Einstellungen oder trage es in appsettings.Local.json ein.';
alertBox.className = 'alert alert-warning';
alertBox.style.display = 'block';
}
async function handleLogin(e) {
e.preventDefault();
const token = document.getElementById('tokenInput').value.trim();
const btn = document.getElementById('submitBtn');
if (!token) return;
btn.disabled = true;
btn.textContent = 'Prüfe …';
alertBox.style.display = 'none';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
if (res.ok) {
window.location.href = '/';
} else {
const data = await res.json().catch(() => ({}));
alertBox.textContent = data.error || 'Ungültiges Zugangs-Token.';
alertBox.className = 'alert alert-error';
alertBox.style.display = 'block';
}
} catch (err) {
alertBox.textContent = 'Verbindungsfehler zum Server.';
alertBox.className = 'alert alert-error';
alertBox.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = 'Anmelden';
}
}
</script>
</body>
</html>