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:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user