Module im WebUI aktivieren und deaktivieren

Umsetzung von #560.

Die Ansicht "Moduleinstellungen" zeigte bisher nur an, ob ein Modul laeuft. Jetzt
laesst sich dort wie im Modul-Tab des Desktops festlegen, welche Module beim
naechsten Start geladen werden. POST /api/modules/{name}/enabled nimmt den
gewuenschten Zustand ausdruecklich entgegen ({"enabled": true|false}) und
schaltet nicht einfach um. Zwei Klicks aus zwei Fenstern heben sich damit
nicht auf, und ein wiederholter Aufruf aendert nichts mehr.

Fehlerfaelle:
- Unbekanntes Modul: 404.
- Modul mit Aktivierungs-Blocker: 409.
- Fehlender Zustand: 400. Die Pruefung war zuerst lueckenhaft, der Test hat es
  gefunden: Mit "bool Enabled" machte ein leerer Rumpf {} daraus false, und ein
  unvollstaendiger Aufruf haette das Modul deaktiviert.

Gemeinsame Quelle statt Kopien:
- KnownModules: Die Modulliste stand viermal im Code (Program, CoreViews,
  ModuleViews, hartkodiert im WebUI). Der Start registriert jetzt die eine
  Liste.
- ModuleActivationView: Die Statusregeln des Desktops ("Aktiv - stoppt nach
  Neustart" usw.) liegen im Core. Desktop und WebUI zeigen dasselbe.

ServerSettingsFile - gefunden, bevor das WebUI in die Datei schreiben durfte:
ServerSettings.Load verschluckt jeden Lesefehler und liefert Vorgaben. "Frisch
lesen, ein Feld aendern, speichern" haette eine gesperrte oder beschaedigte
server_settings.xml durch Vorgaben ersetzt, und mit ihr Lizenzschluessel,
Watchdog-Token, Mullvad-Account und Alchemy-Key. Genau so schrieb auch der
Modul-Schalter des Desktops. ServerSettingsFile:
- liest streng und schreibt bei unlesbarer Datei nichts,
- schreibt atomar ueber eine Nachbardatei,
- erhaelt unter Linux die Dateirechte,
- serialisiert Aenderungen prozessweit.
Der Desktop-Schalter benutzt ihn jetzt ebenfalls.

Tests: Lizenzschluessel, Token und Alchemy-Key bleiben beim Umschalten in der
Datei; eine kaputte Datei bleibt Byte fuer Byte unveraendert und die Anzeige
bietet dann kein Umschalten an; Blocker, 404, 400, Idempotenz; die
Statustabelle; strenges Lesen, atomares Schreiben und Rechte am Dateiobjekt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-13 14:03:31 +02:00
co-authored by Claude Opus 5
parent 6498a3d0ec
commit 68d8d6f8d2
12 changed files with 630 additions and 88 deletions
+107 -14
View File
@@ -4,40 +4,133 @@ using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Modularity;
using PolyTraderSharp.Services;
namespace PolyTrader.Web.Endpoints
{
/// <summary>
/// Moduleinstellungen: welche Module laufen und welche beim nächsten Start geladen werden (#560).
///
/// <para>Dieselbe Quelle und dieselben Regeln wie der Modul-Tab des Desktops: die Modulliste aus
/// <see cref="KnownModules"/>, die Anzeige aus <see cref="ModuleActivationView"/>, geschrieben wird
/// <c>DisabledModules</c> in server_settings.xml über <see cref="ServerSettingsFile"/>. Eine Änderung
/// greift erst nach einem Neustart – Module registrieren ihre Dienste beim Start.</para>
/// </summary>
public static class ModulesEndpoints
{
/// <summary>
/// <c>Enabled</c> ist bewusst nullable: Mit <c>bool</c> machte ein leerer Rumpf <c>{}</c> daraus
/// <c>false</c>, und ein unvollstaendiger Aufruf haette das Modul deaktiviert und die Datei geschrieben.
/// </summary>
public record ModuleEnabledRequest(bool? Enabled);
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 known = sp.GetService<KnownModules>();
if (known == null)
return Results.Problem("Die Modulliste ist nicht verfügbar.", statusCode: StatusCodes.Status503ServiceUnavailable);
var knownModules = new[]
var (disabled, settingsError) = ReadDisabled(sp);
return Results.Ok(known.All.Select(m => ToJson(Describe(sp, m, disabled, settingsError))).ToList());
});
// Ausdrücklich der gewünschte Zustand statt "umschalten": Zwei Klicks aus zwei Browserfenstern
// sollen nicht einander aufheben, und ein wiederholter Aufruf ändert nichts mehr.
group.MapPost("/{name}/enabled", (string name, ModuleEnabledRequest? req, IServiceProvider sp, TerminalLogger logger) =>
{
if (req?.Enabled is not bool enabled)
return Results.BadRequest(new { error = "Gewünschter Zustand fehlt: {\"enabled\": true} oder {\"enabled\": false}." });
var module = sp.GetService<KnownModules>()?.Find(name);
if (module == null)
return Results.NotFound(new { error = $"Modul „{name}“ ist nicht bekannt." });
string? blocker = SafeBlocker(module, sp.GetService<IConfiguration>());
if (!string.IsNullOrEmpty(blocker))
return Results.Json(new { error = $"Modul {module.Name} ist nicht aktivierbar: {blocker}" },
statusCode: StatusCodes.Status409Conflict);
var file = sp.GetService<ServerSettingsFile>();
var failed = PersistenceGuard.Write(file != null, () => file!.Update(settings =>
{
"CopyTrading",
"ResolutionFarming",
"Supervisor",
"Accounting"
};
var set = new HashSet<string>(settings.DisabledModules, StringComparer.OrdinalIgnoreCase);
if (enabled) set.Remove(module.Name);
else set.Add(module.Name);
settings.DisabledModules = set.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
}), logger, $"Modul-Einstellung für {module.Name}");
if (failed != null) return failed;
var list = knownModules.Select(name => new
{
name,
isActive = activeNames.Contains(name)
}).ToList();
logger.Info($"🌐 WebUI: Modul {module.Name} {(enabled ? "aktiviert" : "deaktiviert")} – greift nach dem nächsten Neustart.");
return Results.Ok(list);
var (disabled, settingsError) = ReadDisabled(sp);
return Results.Ok(ToJson(Describe(sp, module, disabled, settingsError)));
});
return routes;
}
/// <summary>
/// Gewünschter Zustand aus der Datei. Ist sie nicht lesbar, bleibt der Zustand unbekannt: Dann wird
/// angezeigt, was läuft, aber nichts zum Umschalten angeboten.
/// </summary>
private static (HashSet<string>? Disabled, string? Error) ReadDisabled(IServiceProvider sp)
{
var file = sp.GetService<ServerSettingsFile>();
if (file == null)
return (null, "Die Einstellungsdatei ist nicht verfügbar.");
try
{
return (new HashSet<string>(file.Read().DisabledModules, StringComparer.OrdinalIgnoreCase), null);
}
catch (Exception ex)
{
return (null, ex.Message);
}
}
private static ModuleActivationView Describe(IServiceProvider sp, IPolyTraderModule module,
HashSet<string>? disabled, string? settingsError)
{
bool running = sp.GetServices<IPolyTraderModule>()
.Any(m => string.Equals(m.Name, module.Name, StringComparison.OrdinalIgnoreCase));
var info = new ModuleActivationInfo(module.Name, running, SafeBlocker(module, sp.GetService<IConfiguration>()));
if (disabled == null)
{
var view = ModuleActivationView.Describe(info, desiredEnabled: running);
return view with { Hint = $"Einstellungen nicht lesbar: {settingsError}", Action = "—", CanToggle = false };
}
return ModuleActivationView.Describe(info, desiredEnabled: !disabled.Contains(module.Name));
}
private static string? SafeBlocker(IPolyTraderModule module, IConfiguration? config)
{
if (config == null) return null;
try { return module.GetActivationBlocker(config); }
catch (Exception ex) { return $"Prüfung fehlgeschlagen: {ex.Message}"; }
}
private static object ToJson(ModuleActivationView v) => new
{
name = v.Name,
isActive = v.IsRunning, // bisheriger Feldname, von Menü und Ansicht weiter benutzt
isRunning = v.IsRunning,
desiredEnabled = v.DesiredEnabled,
blocker = v.Blocker,
status = v.Status,
hint = v.Hint,
action = v.Action,
canToggle = v.CanToggle
};
}
}