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;
///
/// 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.
///
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(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(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