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.AddCorePersistence(databaseOptions);
services.AddSingleton(serverSettings); 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(logger);
services.AddSingleton<TradingState>(); services.AddSingleton<TradingState>();
services.AddSingleton<JobManager>(); services.AddSingleton<JobManager>();
@@ -97,13 +97,7 @@ namespace PolyTrader.App.Avalonia.Shell
var config = services.GetRequiredService<IConfiguration>(); var config = services.GetRequiredService<IConfiguration>();
// Auch die NICHT geladenen Module auflisten – sonst liessen sie sich nie wieder aktivieren. // Auch die NICHT geladenen Module auflisten – sonst liessen sie sich nie wieder aktivieren.
var all = new IPolyTraderModule[] var all = services.GetRequiredService<KnownModules>().All;
{
new PolyTrader.Modules.CopyTrading.CopyTradingModule(),
new PolyTrader.Modules.ResolutionFarming.ResolutionFarmingModule(),
new PolyTrader.Modules.Supervisor.SupervisorModule(),
new PolyTrader.Modules.Accounting.AccountingModule()
};
return all.Select(m => new ModuleActivationInfo( return all.Select(m => new ModuleActivationInfo(
m.Name, m.Name,
@@ -114,14 +114,7 @@ namespace PolyTrader.App.Avalonia.Shell
var config = services.GetService<IConfiguration>(); var config = services.GetService<IConfiguration>();
if (config != null) if (config != null)
{ {
IPolyTraderModule? known = moduleName.ToLowerInvariant() switch IPolyTraderModule? known = services.GetService<KnownModules>()?.Find(moduleName);
{
"copytrading" => new Modules.CopyTrading.CopyTradingModule(),
"resolutionfarming" => new Modules.ResolutionFarming.ResolutionFarmingModule(),
"supervisor" => new Modules.Supervisor.SupervisorModule(),
"accounting" => new Modules.Accounting.AccountingModule(),
_ => null
};
try try
{ {
@@ -1,4 +1,5 @@
using System; using PolyTrader.Core.Configuration;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
@@ -248,34 +249,11 @@ namespace PolyTrader.App.Avalonia.Views
_modules.Clear(); _modules.Clear();
foreach (var m in _moduleInfos) foreach (var m in _moduleInfos)
{ {
bool blocked = !string.IsNullOrEmpty(m.Blocker); // #560: dieselben Regeln wie im WebUI, an einer Stelle (ModuleActivationView).
bool desiredEnabled = !disabled.Contains(m.Name); var view = ModuleActivationView.Describe(m, 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";
}
_modules.Add(new ModuleRow _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 try
{ {
// Frisch aus der Datei lesen, damit parallele Änderungen nicht überschrieben werden. // Frisch lesen, aendern, atomar schreiben (#560). Vorher ServerSettings.Load/Save: Load
var settings = ServerSettings.Load(_settingsPath!); // liefert bei einer unlesbaren Datei still Vorgaben, das anschliessende Save haette damit
var set = new HashSet<string>(settings.DisabledModules, StringComparer.OrdinalIgnoreCase); // Lizenzschluessel und Watchdog-Token ueberschrieben.
new ServerSettingsFile(_settingsPath!).Update(settings =>
if (set.Contains(info.Name)) set.Remove(info.Name); {
else set.Add(info.Name); var set = new HashSet<string>(settings.DisabledModules, StringComparer.OrdinalIgnoreCase);
if (!set.Remove(info.Name)) set.Add(info.Name);
settings.DisabledModules = set.OrderBy(x => x).ToList(); settings.DisabledModules = set.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
settings.Save(_settingsPath!); });
PopulateModules(); PopulateModules();
} }
@@ -0,0 +1,122 @@
using System;
using System.IO;
using System.Xml.Serialization;
using PolyTraderSharp.Models;
namespace PolyTrader.Core.Configuration
{
/// <summary>
/// Lesen und Fortschreiben der <c>server_settings.xml</c> für Änderungen zur Laufzeit (#560, #561).
///
/// <para><b>Warum nicht einfach <see cref="ServerSettings.Load"/> und <see cref="ServerSettings.Save"/>?</b>
/// <c>Load</c> verschluckt jeden Lesefehler und liefert leere Vorgaben. Für den Start ist das gewollt:
/// Eine fehlende oder kaputte Datei soll den Start nicht verhindern. Für "frisch lesen, ein Feld ändern,
/// speichern" ist es gefährlich. Eine gerade gesperrte oder beschädigte Datei würde durch Vorgaben ersetzt,
/// und mit ihr Lizenzschlüssel, Watchdog-Token, Mullvad-Account und Alchemy-Key. <c>Save</c> schreibt
/// zudem direkt in die Zieldatei; ein Absturz mitten im Schreiben hinterließe sie abgeschnitten.</para>
///
/// <para>Hier deshalb:</para>
/// <list type="bullet">
/// <item>Existiert die Datei, muss sie lesbar sein – sonst wird nichts geschrieben.</item>
/// <item>Geschrieben wird in eine Nachbardatei, die dann atomar an die Stelle der alten tritt.</item>
/// <item>Unter Linux behält die Datei ihre Rechte.</item>
/// <item>Änderungen laufen nacheinander, nie zwei Lese-Ändere-Schreibe-Zyklen verschränkt.</item>
/// </list>
/// </summary>
public sealed class ServerSettingsFile
{
/// <summary>
/// Prozessweit, nicht je Instanz: Desktop und WebUI halten eigene Instanzen auf dieselbe Datei.
/// Eine Sperre je Instanz liesse ihre Lese-Aendere-Schreibe-Zyklen trotzdem verschraenken.
/// </summary>
private static readonly object Gate = new();
public ServerSettingsFile(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Pfad der Server-Einstellungen fehlt.", nameof(path));
Path = path;
}
/// <summary>Pfad der Datei, wie ihn auch der Desktop benutzt.</summary>
public string Path { get; }
/// <summary>
/// Liest die aktuelle Datei. Fehlt sie, gelten die Vorgaben. Existiert sie, ist aber nicht lesbar,
/// wirft der Aufruf – anders als <see cref="ServerSettings.Load"/>.
/// </summary>
public ServerSettings Read()
{
lock (Gate)
return ReadStrict();
}
/// <summary>
/// Liest frisch, wendet <paramref name="change"/> an und schreibt atomar zurück. Liefert den
/// geschriebenen Stand. Ist die vorhandene Datei nicht lesbar, wird nichts geschrieben.
/// </summary>
public ServerSettings Update(Action<ServerSettings> change)
{
ArgumentNullException.ThrowIfNull(change);
lock (Gate)
{
var settings = ReadStrict();
change(settings);
WriteAtomic(settings);
return settings;
}
}
private ServerSettings ReadStrict()
{
if (!File.Exists(Path))
return new ServerSettings();
try
{
var serializer = new XmlSerializer(typeof(ServerSettings));
using var fs = new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.Read);
return (ServerSettings?)serializer.Deserialize(fs)
?? throw new InvalidDataException("Die Datei enthält keine Einstellungen.");
}
catch (Exception ex) when (ex is not InvalidDataException)
{
throw new InvalidDataException(
$"{System.IO.Path.GetFileName(Path)} ist nicht lesbar ({ex.Message}). Die Datei wird nicht " +
"angefasst, damit gespeicherte Schlüssel nicht durch Vorgaben ersetzt werden.", ex);
}
}
private void WriteAtomic(ServerSettings settings)
{
string full = System.IO.Path.GetFullPath(Path);
string dir = System.IO.Path.GetDirectoryName(full) ?? ".";
string temp = System.IO.Path.Combine(dir, $".{System.IO.Path.GetFileName(full)}.{Guid.NewGuid():N}.tmp");
UnixFileMode? mode = null;
if (!OperatingSystem.IsWindows() && File.Exists(full))
mode = File.GetUnixFileMode(full);
try
{
using (var fs = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
new XmlSerializer(typeof(ServerSettings)).Serialize(fs, settings);
fs.Flush(flushToDisk: true);
}
if (mode.HasValue)
File.SetUnixFileMode(temp, mode.Value);
File.Move(temp, full, overwrite: true);
}
finally
{
if (File.Exists(temp))
{
try { File.Delete(temp); } catch { /* Aufräumen ist Komfort, kein Muss */ }
}
}
}
}
}
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace PolyTrader.Core.Modularity
{
/// <summary>
/// Alle Module, die diese Installation kennt – geladen oder nicht (#560).
///
/// <para>Die Liste stand bis dahin viermal im Code: beim Start (Program), im Modul-Tab des Desktops
/// (CoreViews), bei den nicht verfügbaren Ansichten (ModuleViews) und hartkodiert im WebUI. Ein neues
/// Modul musste an allen vier Stellen nachgetragen werden. Fehlte es an einer, ließ es sich dort weder
/// sehen noch wieder aktivieren. Der Start registriert jetzt diese eine Liste; wer sie braucht, fragt
/// sie ab.</para>
/// </summary>
public sealed class KnownModules
{
public KnownModules(IEnumerable<IPolyTraderModule> modules)
{
All = (modules ?? throw new ArgumentNullException(nameof(modules))).ToList();
}
public IReadOnlyList<IPolyTraderModule> All { get; }
public IPolyTraderModule? Find(string name) =>
All.FirstOrDefault(m => string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Was ein Modul-Schalter zeigt und darf. Die Regeln stammen aus dem Modul-Tab des Desktops und liegen
/// jetzt hier, damit Desktop und WebUI dasselbe anzeigen (#560).
/// </summary>
public sealed record ModuleActivationView(
string Name,
bool IsRunning,
bool DesiredEnabled,
string? Blocker,
string Status,
string Hint,
string Action,
bool CanToggle)
{
public const string RestartHint = "Änderung greift nach Neustart";
public static ModuleActivationView Describe(ModuleActivationInfo info, bool desiredEnabled)
{
if (!string.IsNullOrEmpty(info.Blocker))
return new(info.Name, info.IsRunning, desiredEnabled, info.Blocker,
"⚠ Nicht aktivierbar", info.Blocker!, "—", false);
if (info.IsRunning && desiredEnabled)
return new(info.Name, true, true, null, "Aktiv", "", "Deaktivieren", true);
if (info.IsRunning)
return new(info.Name, true, false, null, "Aktiv – stoppt nach Neustart", RestartHint, "Aktivieren", true);
if (desiredEnabled)
return new(info.Name, false, true, null, "Startet nach Neustart", RestartHint, "Deaktivieren", true);
return new(info.Name, false, false, null, "Deaktiviert", "", "Aktivieren", true);
}
}
}
+107 -14
View File
@@ -4,40 +4,133 @@ using System.Linq;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Modularity; using PolyTrader.Core.Modularity;
using PolyTraderSharp.Services;
namespace PolyTrader.Web.Endpoints 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 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) public static IEndpointRouteBuilder MapModulesEndpoints(this IEndpointRouteBuilder routes)
{ {
var group = routes.MapGroup("/api/modules"); var group = routes.MapGroup("/api/modules");
group.MapGet("/", (IServiceProvider sp) => group.MapGet("/", (IServiceProvider sp) =>
{ {
var activeModules = sp.GetServices<IPolyTraderModule>().ToList(); var known = sp.GetService<KnownModules>();
var activeNames = activeModules.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); 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", var set = new HashSet<string>(settings.DisabledModules, StringComparer.OrdinalIgnoreCase);
"ResolutionFarming", if (enabled) set.Remove(module.Name);
"Supervisor", else set.Add(module.Name);
"Accounting" 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 logger.Info($"🌐 WebUI: Modul {module.Name} {(enabled ? "aktiviert" : "deaktiviert")} – greift nach dem nächsten Neustart.");
{
name,
isActive = activeNames.Contains(name)
}).ToList();
return Results.Ok(list); var (disabled, settingsError) = ReadDisabled(sp);
return Results.Ok(ToJson(Describe(sp, module, disabled, settingsError)));
}); });
return routes; 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
};
} }
} }
+5 -1
View File
@@ -1148,7 +1148,11 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d
<!-- VIEW: MODULES --> <!-- VIEW: MODULES -->
<section id="view-modules" class="view-panel"> <section id="view-modules" class="view-panel">
<h2 style="margin-bottom: 16px; font-size: 18px;">Moduleinstellungen</h2> <h2 style="margin-bottom: 6px; font-size: 18px;">Moduleinstellungen</h2>
<div class="form-desc" style="margin-bottom: 16px;">
Welche Module beim nächsten Start geladen werden. Änderungen greifen erst nach einem Neustart –
ein Modul registriert seine Dienste beim Start.
</div>
<div class="grid-2" id="modulesContainer"> <div class="grid-2" id="modulesContainer">
<div class="card"><div class="card-title">Lade Module …</div></div> <div class="card"><div class="card-title">Lade Module …</div></div>
</div> </div>
+39 -20
View File
@@ -531,41 +531,60 @@ async function triggerJob(jobName) {
// Modules View // Modules View
async function loadModules() { async function loadModules() {
const container = document.getElementById('modulesContainer');
try { try {
const res = await fetch('/api/modules'); const res = await fetch('/api/modules');
if (!res.ok) return; if (!res.ok) throw new Error(await errorText(res));
const modules = await res.json(); const modules = await res.json();
refreshNavModules(modules); refreshNavModules(modules);
const container = document.getElementById('modulesContainer'); const views = {
copytrading: ['copytrading', 'CopyTrading öffnen →'],
resolutionfarming: ['resolutionfarming', 'Resolution Farming öffnen →'],
supervisor: ['supervisor', 'AI Supervisor öffnen →'],
accounting: ['accounting', 'Accounting & BWA öffnen →']
};
container.innerHTML = modules.map(m => { container.innerHTML = modules.map(m => {
let actionBtn = ''; const view = views[m.name.toLowerCase()];
if (m.name.toLowerCase() === 'copytrading') { const openBtn = view && m.isRunning
actionBtn = `<div style="margin-top: 14px;"><button class="btn btn-secondary btn-sm" onclick="switchView('copytrading')">CopyTrading öffnen →</button></div>`; ? `<button class="btn btn-secondary btn-sm" onclick="switchView(${jsArg(view[0])})">${escapeHtml(view[1])}</button>`
} else if (m.name.toLowerCase() === 'resolutionfarming') { : '';
actionBtn = `<div style="margin-top: 14px;"><button class="btn btn-secondary btn-sm" onclick="switchView('resolutionfarming')">Resolution Farming öffnen →</button></div>`; // #560: ausdruecklich der gewuenschte Zustand, nicht "umschalten".
} else if (m.name.toLowerCase() === 'supervisor') { const toggleBtn = m.canToggle
actionBtn = `<div style="margin-top: 14px;"><button class="btn btn-secondary btn-sm" onclick="switchView('supervisor')">AI Supervisor öffnen →</button></div>`; ? `<button class="btn ${m.desiredEnabled ? 'btn-danger' : 'btn-primary'} btn-sm" onclick="setModuleEnabled(${jsArg(m.name)}, ${m.desiredEnabled ? 'false' : 'true'})">${escapeHtml(m.action)}</button>`
} else if (m.name.toLowerCase() === 'accounting') { : '';
actionBtn = `<div style="margin-top: 14px;"><button class="btn btn-secondary btn-sm" onclick="switchView('accounting')">Accounting & BWA öffnen →</button></div>`; const statusColor = m.blocker ? 'var(--warning)' : (m.isRunning ? 'var(--positive)' : 'var(--muted)');
}
return ` return `
<div class="card"> <div class="card">
<div class="card-title"> <div class="card-title">
<span>${escapeHtml(m.name)}</span> <span>${escapeHtml(m.name)}</span>
<span style="font-size: 12px; color: ${m.isActive ? 'var(--positive)' : 'var(--muted)'}; font-weight: normal;"> <span style="font-size: 12px; color: ${statusColor}; font-weight: normal;">● ${escapeHtml(m.status)}</span>
● ${m.isActive ? 'Aktiv im Betrieb' : 'Deaktiviert'}
</span>
</div> </div>
<div style="font-size: 13px; color: var(--muted);"> <div style="font-size: 13px; color: var(--muted); min-height: 18px;">${escapeHtml(m.hint || (m.isRunning ? 'Läuft planmäßig im Hintergrund.' : 'In dieser Sitzung nicht geladen.'))}</div>
${m.isActive ? 'Modul läuft planmäßig im Hintergrund.' : 'Nicht geladen (in den Servereinstellungen oder über DisabledModules deaktiviert).'} <div style="margin-top: 14px; display: flex; gap: 8px; flex-wrap: wrap;">${toggleBtn}${openBtn}</div>
</div>
${actionBtn}
</div> </div>
`; `;
}).join(''); }).join('');
} catch (e) {} } catch (e) {
container.innerHTML = `<div class="card"><div class="card-title">Module nicht ladbar</div><div style="font-size: 13px; color: var(--negative);">${escapeHtml(e.message)}</div></div>`;
}
}
async function setModuleEnabled(name, enabled) {
if (!enabled && !confirm(`Modul ${name} deaktivieren?\n\nEs läuft bis zum nächsten Neustart weiter und wird danach nicht mehr geladen.`)) return;
try {
const res = await fetch(`/api/modules/${encodeURIComponent(name)}/enabled`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
if (!res.ok) throw new Error(await errorText(res));
} catch (e) {
alert(`Modul-Einstellung nicht gespeichert:\n\n${e.message}`);
}
loadModules();
} }
// Terminal & SSE Logs // Terminal & SSE Logs
@@ -0,0 +1,93 @@
using System;
using System.IO;
using System.Linq;
using PolyTrader.Core.Configuration;
using PolyTraderSharp.Models;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// Fortschreiben der server_settings.xml zur Laufzeit (#560). Der Kern ist, was NICHT passieren darf:
/// dass eine unlesbare Datei still durch Vorgaben ersetzt wird und gespeicherte Schlüssel verschwinden.
/// </summary>
public sealed class ServerSettingsFileTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "polytrader-settings-" + Guid.NewGuid().ToString("N"));
private string SettingsPath => Path.Combine(_dir, "server_settings.xml");
public ServerSettingsFileTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
[Fact]
public void Fehlende_Datei_liefert_Vorgaben_und_wird_beim_Schreiben_angelegt()
{
var file = new ServerSettingsFile(SettingsPath);
Assert.Empty(file.Read().DisabledModules);
file.Update(s => s.DisabledModules.Add("Supervisor"));
Assert.Equal(new[] { "Supervisor" }, ServerSettings.Load(SettingsPath).DisabledModules);
}
[Fact]
public void Update_aendert_nur_das_eine_Feld()
{
new ServerSettings { LicenseKey = "enc:v1:L", WatchdogToken = "enc:v1:W", MullvadAccount = "1234", VpnLocation = "cz" }
.Save(SettingsPath);
new ServerSettingsFile(SettingsPath).Update(s => s.VpnLocation = "se");
var written = ServerSettings.Load(SettingsPath);
Assert.Equal("se", written.VpnLocation);
Assert.Equal("enc:v1:L", written.LicenseKey);
Assert.Equal("enc:v1:W", written.WatchdogToken);
Assert.Equal("1234", written.MullvadAccount);
}
[Fact]
public void Unlesbare_Datei_wird_nicht_ueberschrieben()
{
const string kaputt = "<ServerSettings><LicenseKey>enc:v1:L</LicenseKey><abgeschnitten";
File.WriteAllText(SettingsPath, kaputt);
var file = new ServerSettingsFile(SettingsPath);
Assert.Throws<InvalidDataException>(() => file.Read());
var ex = Assert.Throws<InvalidDataException>(() => file.Update(s => s.VpnLocation = "se"));
Assert.Contains("nicht lesbar", ex.Message);
Assert.Equal(kaputt, File.ReadAllText(SettingsPath));
// Zum Vergleich das Verhalten, vor dem das schützt: Load liefert still Vorgaben.
Assert.Equal(string.Empty, ServerSettings.Load(SettingsPath).LicenseKey);
}
[Fact]
public void Kein_Rest_einer_Zwischendatei_bleibt_liegen()
{
var file = new ServerSettingsFile(SettingsPath);
file.Update(s => s.VpnLocation = "se");
file.Update(s => s.VpnLocation = "de");
Assert.Equal(new[] { "server_settings.xml" }, Directory.GetFiles(_dir).Select(Path.GetFileName).ToArray());
}
[Fact]
public void Dateirechte_bleiben_unter_Linux_erhalten()
{
if (OperatingSystem.IsWindows()) return; // NTFS-Rechte vererben sich, dort ist nichts zu tun
new ServerSettings().Save(SettingsPath);
File.SetUnixFileMode(SettingsPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
new ServerSettingsFile(SettingsPath).Update(s => s.VpnLocation = "se");
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(SettingsPath));
}
}
}
@@ -0,0 +1,159 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Modularity;
using PolyTraderSharp.Models;
using Xunit;
namespace PolyTrader.Tests.WebUi
{
/// <summary>
/// Moduleinstellungen über das WebUI (#560): anzeigen, aktivieren, deaktivieren - und die
/// Einstellungsdatei dabei nie beschädigen.
/// </summary>
public class ModuleSettingsEndpointTests
{
private sealed class FakeModule : IPolyTraderModule
{
private readonly string? _blocker;
public FakeModule(string name, string? blocker = null) { Name = name; _blocker = blocker; }
public string Name { get; }
public string DbPrefix => "fake_";
public void RegisterServices(IServiceCollection services, IConfiguration configuration) { }
public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
public string? GetActivationBlocker(IConfiguration configuration) => _blocker;
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private static JsonElement ByName(JsonElement modules, string name) =>
modules.EnumerateArray().Single(m => m.GetProperty("name").GetString() == name);
[Fact]
public async Task Laufendes_Modul_ohne_Eintrag_ist_aktiv_die_uebrigen_starten_nach_Neustart()
{
await using var web = await WebUiTestHost.StartAsync(s =>
s.AddSingleton<IPolyTraderModule>(new FakeModule("CopyTrading")));
var modules = await web.GetJsonAsync("/api/modules");
var ct = ByName(modules, "CopyTrading");
Assert.Equal("Aktiv", ct.GetProperty("status").GetString());
Assert.Equal("Deaktivieren", ct.GetProperty("action").GetString());
Assert.True(ct.GetProperty("canToggle").GetBoolean());
Assert.Equal("Startet nach Neustart", ByName(modules, "Supervisor").GetProperty("status").GetString());
}
[Fact]
public async Task Deaktivieren_schreibt_DisabledModules_und_laesst_Schluessel_in_der_Datei_stehen()
{
await using var web = await WebUiTestHost.StartAsync(s =>
s.AddSingleton<IPolyTraderModule>(new FakeModule("CopyTrading")));
new ServerSettings
{
LicenseKey = "enc:v1:LIZENZ",
WatchdogToken = "enc:v1:WATCHDOG",
PolygonRpcUrl = "wss://polygon-mainnet.g.alchemy.com/v2/ALCHEMY",
VpnLocation = "se"
}.Save(web.SettingsPath);
var (status, body) = await web.PostJsonAsync("/api/modules/copytrading/enabled", new { enabled = false });
Assert.Equal(200, status);
Assert.Equal("Aktiv – stoppt nach Neustart", body.GetProperty("status").GetString());
var written = ServerSettings.Load(web.SettingsPath);
Assert.Equal(new[] { "CopyTrading" }, written.DisabledModules); // kanonischer Name, nicht "copytrading"
Assert.Equal("enc:v1:LIZENZ", written.LicenseKey);
Assert.Equal("enc:v1:WATCHDOG", written.WatchdogToken);
Assert.Equal("wss://polygon-mainnet.g.alchemy.com/v2/ALCHEMY", written.PolygonRpcUrl);
Assert.Equal("se", written.VpnLocation);
}
[Fact]
public async Task Wiederholter_Aufruf_aendert_nichts_mehr_und_Aktivieren_nimmt_den_Eintrag_zurueck()
{
await using var web = await WebUiTestHost.StartAsync();
await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = false });
await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = false });
Assert.Equal(new[] { "Supervisor" }, ServerSettings.Load(web.SettingsPath).DisabledModules);
var (status, body) = await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = true });
Assert.Equal(200, status);
Assert.Empty(ServerSettings.Load(web.SettingsPath).DisabledModules);
Assert.True(body.GetProperty("desiredEnabled").GetBoolean());
}
[Fact]
public async Task Unbekanntes_Modul_404_fehlender_Zustand_400()
{
await using var web = await WebUiTestHost.StartAsync();
var (unknown, _) = await web.PostJsonAsync("/api/modules/GibtEsNicht/enabled", new { enabled = false });
var (missing, _) = await web.PostJsonAsync("/api/modules/Supervisor/enabled");
Assert.Equal(404, unknown);
Assert.Equal(400, missing);
Assert.False(File.Exists(web.SettingsPath)); // nichts geschrieben
}
[Fact]
public async Task Modul_mit_Blocker_laesst_sich_nicht_schalten()
{
await using var web = await WebUiTestHost.StartAsync(s =>
s.AddSingleton(new KnownModules(new IPolyTraderModule[] { new FakeModule("Supervisor", blocker: "OpenRouter-Key fehlt") })));
var modules = await web.GetJsonAsync("/api/modules");
var sup = ByName(modules, "Supervisor");
Assert.False(sup.GetProperty("canToggle").GetBoolean());
Assert.Equal("OpenRouter-Key fehlt", sup.GetProperty("hint").GetString());
var (status, _) = await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = true });
Assert.Equal(409, status);
}
[Fact]
public async Task Unlesbare_Einstellungsdatei_wird_nicht_angefasst()
{
await using var web = await WebUiTestHost.StartAsync();
const string kaputt = "<ServerSettings><LicenseKey>enc:v1:LIZENZ</LicenseKey><abgeschnitten";
File.WriteAllText(web.SettingsPath, kaputt);
var (status, body) = await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = false });
Assert.Equal(500, status);
Assert.Contains("nicht lesbar", body.GetProperty("detail").GetString());
Assert.Equal(kaputt, File.ReadAllText(web.SettingsPath)); // Byte fuer Byte unveraendert
// Anzeigen geht weiter, umschalten wird nicht angeboten.
var modules = await web.GetJsonAsync("/api/modules");
Assert.All(modules.EnumerateArray(), m => Assert.False(m.GetProperty("canToggle").GetBoolean()));
}
// ---- ModuleActivationView ---------------------------------------------
[Theory]
[InlineData(true, true, null, "Aktiv", "Deaktivieren", true)]
[InlineData(true, false, null, "Aktiv – stoppt nach Neustart", "Aktivieren", true)]
[InlineData(false, true, null, "Startet nach Neustart", "Deaktivieren", true)]
[InlineData(false, false, null, "Deaktiviert", "Aktivieren", true)]
[InlineData(false, true, "API-Key fehlt", "⚠ Nicht aktivierbar", "—", false)]
public void Anzeige_folgt_den_Regeln_des_Desktops(bool running, bool desired, string? blocker,
string status, string action, bool canToggle)
{
var view = ModuleActivationView.Describe(new ModuleActivationInfo("X", running, blocker), desired);
Assert.Equal(status, view.Status);
Assert.Equal(action, view.Action);
Assert.Equal(canToggle, view.CanToggle);
}
}
}
+21 -2
View File
@@ -37,6 +37,7 @@ namespace PolyTrader.Tests.WebUi
public const string Token = "webui-test-token-7f3a9c1e5b2d4f6a8c0e"; public const string Token = "webui-test-token-7f3a9c1e5b2d4f6a8c0e";
private readonly IHost _host; private readonly IHost _host;
private readonly string _settingsDirectory;
public TradingState State { get; } public TradingState State { get; }
public CopyTradingState CopyState { get; } public CopyTradingState CopyState { get; }
@@ -44,6 +45,9 @@ namespace PolyTrader.Tests.WebUi
public JobManager Jobs { get; } public JobManager Jobs { get; }
public WebUiOptions WebOptions { get; } public WebUiOptions WebOptions { get; }
/// <summary>Eigene server_settings.xml je Testhost in einem temporaeren Ordner - nie die echte.</summary>
public string SettingsPath => System.IO.Path.Combine(_settingsDirectory, "server_settings.xml");
/// <summary>Mit gueltigem Token.</summary> /// <summary>Mit gueltigem Token.</summary>
public HttpClient Client { get; } public HttpClient Client { get; }
@@ -51,9 +55,10 @@ namespace PolyTrader.Tests.WebUi
public HttpClient Anonymous { get; } public HttpClient Anonymous { get; }
private WebUiTestHost(IHost host, TradingState state, CopyTradingState copyState, private WebUiTestHost(IHost host, TradingState state, CopyTradingState copyState,
TerminalLogger logger, JobManager jobs, WebUiOptions options) TerminalLogger logger, JobManager jobs, WebUiOptions options, string settingsDirectory)
{ {
_host = host; _host = host;
_settingsDirectory = settingsDirectory;
State = state; State = state;
CopyState = copyState; CopyState = copyState;
Logger = logger; Logger = logger;
@@ -82,6 +87,8 @@ namespace PolyTrader.Tests.WebUi
var jobs = new JobManager(); var jobs = new JobManager();
var options = new WebUiOptions { Enabled = true, AuthToken = Token }; var options = new WebUiOptions { Enabled = true, AuthToken = Token };
configureOptions?.Invoke(options); configureOptions?.Invoke(options);
string settingsDirectory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "polytrader-webui-" + Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(settingsDirectory);
var host = await new HostBuilder() var host = await new HostBuilder()
.ConfigureWebHost(web => .ConfigureWebHost(web =>
@@ -95,13 +102,24 @@ namespace PolyTrader.Tests.WebUi
services.AddSingleton(copyState); services.AddSingleton(copyState);
services.AddSingleton(logger); services.AddSingleton(logger);
services.AddSingleton(jobs); services.AddSingleton(jobs);
// Wie im Betrieb (Program): alle Module und die Einstellungsdatei. Tests koennen beides
// in configureServices ersetzen - die letzte Registrierung gewinnt.
services.AddSingleton(new PolyTrader.Core.Modularity.KnownModules(new PolyTrader.Core.Modularity.IPolyTraderModule[]
{
new PolyTrader.Modules.CopyTrading.CopyTradingModule(),
new PolyTrader.Modules.ResolutionFarming.ResolutionFarmingModule(),
new PolyTrader.Modules.Supervisor.SupervisorModule(),
new PolyTrader.Modules.Accounting.AccountingModule()
}));
services.AddSingleton(new PolyTrader.Core.Configuration.ServerSettingsFile(
System.IO.Path.Combine(settingsDirectory, "server_settings.xml")));
configureServices?.Invoke(services); configureServices?.Invoke(services);
}); });
web.Configure(app => app.UsePolyTraderWebPipeline()); web.Configure(app => app.UsePolyTraderWebPipeline());
}) })
.StartAsync(); .StartAsync();
return new WebUiTestHost(host, state, copyState, logger, jobs, options); return new WebUiTestHost(host, state, copyState, logger, jobs, options, settingsDirectory);
} }
/// <summary>CopyTrading-Repositories auf einer eigenen InMemory-Datenbank.</summary> /// <summary>CopyTrading-Repositories auf einer eigenen InMemory-Datenbank.</summary>
@@ -158,6 +176,7 @@ namespace PolyTrader.Tests.WebUi
Anonymous.Dispose(); Anonymous.Dispose();
await _host.StopAsync(); await _host.StopAsync();
_host.Dispose(); _host.Dispose();
try { System.IO.Directory.Delete(_settingsDirectory, recursive: true); } catch { /* temporaer, darf liegen bleiben */ }
} }
} }
} }