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
@@ -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;
}
}
}