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
+5
View File
@@ -288,6 +288,11 @@ namespace PolyTrader.App.Avalonia
services.AddCorePersistence(databaseOptions);
services.AddSingleton(serverSettings);
// #560: die eine Liste aller Module (auch der nicht geladenen) und der eine Weg, die
// Einstellungsdatei zur Laufzeit fortzuschreiben - gemeinsam fuer Desktop und WebUI.
services.AddSingleton(new KnownModules(allModules));
services.AddSingleton(new ServerSettingsFile(ServerSettingsPath));
services.AddSingleton(logger);
services.AddSingleton<TradingState>();
services.AddSingleton<JobManager>();
@@ -97,13 +97,7 @@ namespace PolyTrader.App.Avalonia.Shell
var config = services.GetRequiredService<IConfiguration>();
// Auch die NICHT geladenen Module auflisten – sonst liessen sie sich nie wieder aktivieren.
var all = new IPolyTraderModule[]
{
new PolyTrader.Modules.CopyTrading.CopyTradingModule(),
new PolyTrader.Modules.ResolutionFarming.ResolutionFarmingModule(),
new PolyTrader.Modules.Supervisor.SupervisorModule(),
new PolyTrader.Modules.Accounting.AccountingModule()
};
var all = services.GetRequiredService<KnownModules>().All;
return all.Select(m => new ModuleActivationInfo(
m.Name,
@@ -114,14 +114,7 @@ namespace PolyTrader.App.Avalonia.Shell
var config = services.GetService<IConfiguration>();
if (config != null)
{
IPolyTraderModule? known = moduleName.ToLowerInvariant() switch
{
"copytrading" => new Modules.CopyTrading.CopyTradingModule(),
"resolutionfarming" => new Modules.ResolutionFarming.ResolutionFarmingModule(),
"supervisor" => new Modules.Supervisor.SupervisorModule(),
"accounting" => new Modules.Accounting.AccountingModule(),
_ => null
};
IPolyTraderModule? known = services.GetService<KnownModules>()?.Find(moduleName);
try
{
@@ -1,4 +1,5 @@
using System;
using PolyTrader.Core.Configuration;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
@@ -248,34 +249,11 @@ namespace PolyTrader.App.Avalonia.Views
_modules.Clear();
foreach (var m in _moduleInfos)
{
bool blocked = !string.IsNullOrEmpty(m.Blocker);
bool desiredEnabled = !disabled.Contains(m.Name);
string status, hint, action;
if (blocked)
{
status = "⚠ Nicht aktivierbar"; hint = m.Blocker!; action = "—";
}
else if (m.IsRunning && desiredEnabled)
{
status = "Aktiv"; hint = ""; action = "Deaktivieren";
}
else if (m.IsRunning && !desiredEnabled)
{
status = "Aktiv – stoppt nach Neustart"; hint = "Änderung greift nach Neustart"; action = "Aktivieren";
}
else if (!m.IsRunning && desiredEnabled)
{
status = "Startet nach Neustart"; hint = "Änderung greift nach Neustart"; action = "Deaktivieren";
}
else
{
status = "Deaktiviert"; hint = ""; action = "Aktivieren";
}
// #560: dieselben Regeln wie im WebUI, an einer Stelle (ModuleActivationView).
var view = ModuleActivationView.Describe(m, desiredEnabled: !disabled.Contains(m.Name));
_modules.Add(new ModuleRow
{
Modul = m.Name, Status = status, Hinweis = hint, Aktion = action, CanToggle = !blocked
Modul = view.Name, Status = view.Status, Hinweis = view.Hint, Aktion = view.Action, CanToggle = view.CanToggle
});
}
}
@@ -290,15 +268,15 @@ namespace PolyTrader.App.Avalonia.Views
try
{
// Frisch aus der Datei lesen, damit parallele Änderungen nicht überschrieben werden.
var settings = ServerSettings.Load(_settingsPath!);
var set = new HashSet<string>(settings.DisabledModules, StringComparer.OrdinalIgnoreCase);
if (set.Contains(info.Name)) set.Remove(info.Name);
else set.Add(info.Name);
settings.DisabledModules = set.OrderBy(x => x).ToList();
settings.Save(_settingsPath!);
// Frisch lesen, aendern, atomar schreiben (#560). Vorher ServerSettings.Load/Save: Load
// liefert bei einer unlesbaren Datei still Vorgaben, das anschliessende Save haette damit
// Lizenzschluessel und Watchdog-Token ueberschrieben.
new ServerSettingsFile(_settingsPath!).Update(settings =>
{
var set = new HashSet<string>(settings.DisabledModules, StringComparer.OrdinalIgnoreCase);
if (!set.Remove(info.Name)) set.Add(info.Name);
settings.DisabledModules = set.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
});
PopulateModules();
}