From 68d8d6f8d277c20a2bb26f569bf78ee7e1b58de6 Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 13 Sep 2026 14:03:31 +0200 Subject: [PATCH] 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 --- src/PolyTrader.App.Avalonia/Program.cs | 5 + .../Shell/CoreViews.cs | 8 +- .../Shell/ModuleViews.cs | 9 +- .../Views/DashboardWindow.axaml.cs | 50 ++---- .../Configuration/ServerSettingsFile.cs | 122 ++++++++++++++ .../Modularity/KnownModules.cs | 63 +++++++ .../Endpoints/ModulesEndpoints.cs | 121 +++++++++++-- src/PolyTrader.Web/wwwroot/index.html | 6 +- src/PolyTrader.Web/wwwroot/js/app.js | 59 ++++--- .../ServerSettingsFileTests.cs | 93 ++++++++++ .../WebUi/ModuleSettingsEndpointTests.cs | 159 ++++++++++++++++++ tests/PolyTrader.Tests/WebUi/WebUiTestHost.cs | 23 ++- 12 files changed, 630 insertions(+), 88 deletions(-) create mode 100644 src/PolyTrader.Core/Configuration/ServerSettingsFile.cs create mode 100644 src/PolyTrader.Core/Modularity/KnownModules.cs create mode 100644 tests/PolyTrader.Tests/ServerSettingsFileTests.cs create mode 100644 tests/PolyTrader.Tests/WebUi/ModuleSettingsEndpointTests.cs diff --git a/src/PolyTrader.App.Avalonia/Program.cs b/src/PolyTrader.App.Avalonia/Program.cs index cabe58b..1318629 100644 --- a/src/PolyTrader.App.Avalonia/Program.cs +++ b/src/PolyTrader.App.Avalonia/Program.cs @@ -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(); services.AddSingleton(); diff --git a/src/PolyTrader.App.Avalonia/Shell/CoreViews.cs b/src/PolyTrader.App.Avalonia/Shell/CoreViews.cs index 8f540c7..c650fc3 100644 --- a/src/PolyTrader.App.Avalonia/Shell/CoreViews.cs +++ b/src/PolyTrader.App.Avalonia/Shell/CoreViews.cs @@ -97,13 +97,7 @@ namespace PolyTrader.App.Avalonia.Shell var config = services.GetRequiredService(); // 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().All; return all.Select(m => new ModuleActivationInfo( m.Name, diff --git a/src/PolyTrader.App.Avalonia/Shell/ModuleViews.cs b/src/PolyTrader.App.Avalonia/Shell/ModuleViews.cs index d16fb00..f8fc916 100644 --- a/src/PolyTrader.App.Avalonia/Shell/ModuleViews.cs +++ b/src/PolyTrader.App.Avalonia/Shell/ModuleViews.cs @@ -114,14 +114,7 @@ namespace PolyTrader.App.Avalonia.Shell var config = services.GetService(); 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()?.Find(moduleName); try { diff --git a/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml.cs b/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml.cs index 3121b59..5cd9694 100644 --- a/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml.cs +++ b/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml.cs @@ -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(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(settings.DisabledModules, StringComparer.OrdinalIgnoreCase); + if (!set.Remove(info.Name)) set.Add(info.Name); + settings.DisabledModules = set.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + }); PopulateModules(); } diff --git a/src/PolyTrader.Core/Configuration/ServerSettingsFile.cs b/src/PolyTrader.Core/Configuration/ServerSettingsFile.cs new file mode 100644 index 0000000..fd766f8 --- /dev/null +++ b/src/PolyTrader.Core/Configuration/ServerSettingsFile.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using System.Xml.Serialization; +using PolyTraderSharp.Models; + +namespace PolyTrader.Core.Configuration +{ + /// + /// Lesen und Fortschreiben der server_settings.xml für Änderungen zur Laufzeit (#560, #561). + /// + /// Warum nicht einfach und ? + /// Load 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. Save schreibt + /// zudem direkt in die Zieldatei; ein Absturz mitten im Schreiben hinterließe sie abgeschnitten. + /// + /// Hier deshalb: + /// + /// Existiert die Datei, muss sie lesbar sein – sonst wird nichts geschrieben. + /// Geschrieben wird in eine Nachbardatei, die dann atomar an die Stelle der alten tritt. + /// Unter Linux behält die Datei ihre Rechte. + /// Änderungen laufen nacheinander, nie zwei Lese-Ändere-Schreibe-Zyklen verschränkt. + /// + /// + public sealed class ServerSettingsFile + { + /// + /// 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. + /// + 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; + } + + /// Pfad der Datei, wie ihn auch der Desktop benutzt. + public string Path { get; } + + /// + /// Liest die aktuelle Datei. Fehlt sie, gelten die Vorgaben. Existiert sie, ist aber nicht lesbar, + /// wirft der Aufruf – anders als . + /// + public ServerSettings Read() + { + lock (Gate) + return ReadStrict(); + } + + /// + /// Liest frisch, wendet an und schreibt atomar zurück. Liefert den + /// geschriebenen Stand. Ist die vorhandene Datei nicht lesbar, wird nichts geschrieben. + /// + public ServerSettings Update(Action 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 */ } + } + } + } + } +} diff --git a/src/PolyTrader.Core/Modularity/KnownModules.cs b/src/PolyTrader.Core/Modularity/KnownModules.cs new file mode 100644 index 0000000..0516bb3 --- /dev/null +++ b/src/PolyTrader.Core/Modularity/KnownModules.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PolyTrader.Core.Modularity +{ + /// + /// Alle Module, die diese Installation kennt – geladen oder nicht (#560). + /// + /// 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. + /// + public sealed class KnownModules + { + public KnownModules(IEnumerable modules) + { + All = (modules ?? throw new ArgumentNullException(nameof(modules))).ToList(); + } + + public IReadOnlyList All { get; } + + public IPolyTraderModule? Find(string name) => + All.FirstOrDefault(m => string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// 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). + /// + 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); + } + } +} diff --git a/src/PolyTrader.Web/Endpoints/ModulesEndpoints.cs b/src/PolyTrader.Web/Endpoints/ModulesEndpoints.cs index b39108f..59fe664 100644 --- a/src/PolyTrader.Web/Endpoints/ModulesEndpoints.cs +++ b/src/PolyTrader.Web/Endpoints/ModulesEndpoints.cs @@ -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 { + /// + /// Moduleinstellungen: welche Module laufen und welche beim nächsten Start geladen werden (#560). + /// + /// Dieselbe Quelle und dieselben Regeln wie der Modul-Tab des Desktops: die Modulliste aus + /// , die Anzeige aus , geschrieben wird + /// DisabledModules in server_settings.xml über . Eine Änderung + /// greift erst nach einem Neustart – Module registrieren ihre Dienste beim Start. + /// public static class ModulesEndpoints { + /// + /// Enabled ist bewusst nullable: Mit bool machte ein leerer Rumpf {} daraus + /// false, und ein unvollstaendiger Aufruf haette das Modul deaktiviert und die Datei geschrieben. + /// + 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().ToList(); - var activeNames = activeModules.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + var known = sp.GetService(); + 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()?.Find(name); + if (module == null) + return Results.NotFound(new { error = $"Modul „{name}“ ist nicht bekannt." }); + + string? blocker = SafeBlocker(module, sp.GetService()); + if (!string.IsNullOrEmpty(blocker)) + return Results.Json(new { error = $"Modul {module.Name} ist nicht aktivierbar: {blocker}" }, + statusCode: StatusCodes.Status409Conflict); + + var file = sp.GetService(); + var failed = PersistenceGuard.Write(file != null, () => file!.Update(settings => { - "CopyTrading", - "ResolutionFarming", - "Supervisor", - "Accounting" - }; + var set = new HashSet(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; } + + /// + /// 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. + /// + private static (HashSet? Disabled, string? Error) ReadDisabled(IServiceProvider sp) + { + var file = sp.GetService(); + if (file == null) + return (null, "Die Einstellungsdatei ist nicht verfügbar."); + + try + { + return (new HashSet(file.Read().DisabledModules, StringComparer.OrdinalIgnoreCase), null); + } + catch (Exception ex) + { + return (null, ex.Message); + } + } + + private static ModuleActivationView Describe(IServiceProvider sp, IPolyTraderModule module, + HashSet? disabled, string? settingsError) + { + bool running = sp.GetServices() + .Any(m => string.Equals(m.Name, module.Name, StringComparison.OrdinalIgnoreCase)); + var info = new ModuleActivationInfo(module.Name, running, SafeBlocker(module, sp.GetService())); + + 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 + }; } } diff --git a/src/PolyTrader.Web/wwwroot/index.html b/src/PolyTrader.Web/wwwroot/index.html index b0c8db6..2d61fe1 100644 --- a/src/PolyTrader.Web/wwwroot/index.html +++ b/src/PolyTrader.Web/wwwroot/index.html @@ -1148,7 +1148,11 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d
-

Moduleinstellungen

+

Moduleinstellungen

+
+ Welche Module beim nächsten Start geladen werden. Änderungen greifen erst nach einem Neustart – + ein Modul registriert seine Dienste beim Start. +
Lade Module …
diff --git a/src/PolyTrader.Web/wwwroot/js/app.js b/src/PolyTrader.Web/wwwroot/js/app.js index 1dee65b..cb801d3 100644 --- a/src/PolyTrader.Web/wwwroot/js/app.js +++ b/src/PolyTrader.Web/wwwroot/js/app.js @@ -531,41 +531,60 @@ async function triggerJob(jobName) { // Modules View async function loadModules() { + const container = document.getElementById('modulesContainer'); try { const res = await fetch('/api/modules'); - if (!res.ok) return; + if (!res.ok) throw new Error(await errorText(res)); const modules = await res.json(); 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 => { - let actionBtn = ''; - if (m.name.toLowerCase() === 'copytrading') { - actionBtn = `
`; - } else if (m.name.toLowerCase() === 'resolutionfarming') { - actionBtn = `
`; - } else if (m.name.toLowerCase() === 'supervisor') { - actionBtn = `
`; - } else if (m.name.toLowerCase() === 'accounting') { - actionBtn = `
`; - } + const view = views[m.name.toLowerCase()]; + const openBtn = view && m.isRunning + ? `` + : ''; + // #560: ausdruecklich der gewuenschte Zustand, nicht "umschalten". + const toggleBtn = m.canToggle + ? `` + : ''; + const statusColor = m.blocker ? 'var(--warning)' : (m.isRunning ? 'var(--positive)' : 'var(--muted)'); return `
${escapeHtml(m.name)} - - ● ${m.isActive ? 'Aktiv im Betrieb' : 'Deaktiviert'} - + ● ${escapeHtml(m.status)}
-
- ${m.isActive ? 'Modul läuft planmäßig im Hintergrund.' : 'Nicht geladen (in den Servereinstellungen oder über DisabledModules deaktiviert).'} -
- ${actionBtn} +
${escapeHtml(m.hint || (m.isRunning ? 'Läuft planmäßig im Hintergrund.' : 'In dieser Sitzung nicht geladen.'))}
+
${toggleBtn}${openBtn}
`; }).join(''); - } catch (e) {} + } catch (e) { + container.innerHTML = `
Module nicht ladbar
${escapeHtml(e.message)}
`; + } +} + +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 diff --git a/tests/PolyTrader.Tests/ServerSettingsFileTests.cs b/tests/PolyTrader.Tests/ServerSettingsFileTests.cs new file mode 100644 index 0000000..362792c --- /dev/null +++ b/tests/PolyTrader.Tests/ServerSettingsFileTests.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using System.Linq; +using PolyTrader.Core.Configuration; +using PolyTraderSharp.Models; +using Xunit; + +namespace PolyTrader.Tests +{ + /// + /// 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. + /// + 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 = "enc:v1:L(() => file.Read()); + var ex = Assert.Throws(() => 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)); + } + } +} diff --git a/tests/PolyTrader.Tests/WebUi/ModuleSettingsEndpointTests.cs b/tests/PolyTrader.Tests/WebUi/ModuleSettingsEndpointTests.cs new file mode 100644 index 0000000..a03c8f9 --- /dev/null +++ b/tests/PolyTrader.Tests/WebUi/ModuleSettingsEndpointTests.cs @@ -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 +{ + /// + /// Moduleinstellungen über das WebUI (#560): anzeigen, aktivieren, deaktivieren - und die + /// Einstellungsdatei dabei nie beschädigen. + /// + 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(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(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 = "enc:v1:LIZENZ 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); + } + } +} diff --git a/tests/PolyTrader.Tests/WebUi/WebUiTestHost.cs b/tests/PolyTrader.Tests/WebUi/WebUiTestHost.cs index 7b7080c..ff381ad 100644 --- a/tests/PolyTrader.Tests/WebUi/WebUiTestHost.cs +++ b/tests/PolyTrader.Tests/WebUi/WebUiTestHost.cs @@ -37,6 +37,7 @@ namespace PolyTrader.Tests.WebUi public const string Token = "webui-test-token-7f3a9c1e5b2d4f6a8c0e"; private readonly IHost _host; + private readonly string _settingsDirectory; public TradingState State { get; } public CopyTradingState CopyState { get; } @@ -44,6 +45,9 @@ namespace PolyTrader.Tests.WebUi public JobManager Jobs { get; } public WebUiOptions WebOptions { get; } + /// Eigene server_settings.xml je Testhost in einem temporaeren Ordner - nie die echte. + public string SettingsPath => System.IO.Path.Combine(_settingsDirectory, "server_settings.xml"); + /// Mit gueltigem Token. public HttpClient Client { get; } @@ -51,9 +55,10 @@ namespace PolyTrader.Tests.WebUi public HttpClient Anonymous { get; } private WebUiTestHost(IHost host, TradingState state, CopyTradingState copyState, - TerminalLogger logger, JobManager jobs, WebUiOptions options) + TerminalLogger logger, JobManager jobs, WebUiOptions options, string settingsDirectory) { _host = host; + _settingsDirectory = settingsDirectory; State = state; CopyState = copyState; Logger = logger; @@ -82,6 +87,8 @@ namespace PolyTrader.Tests.WebUi var jobs = new JobManager(); var options = new WebUiOptions { Enabled = true, AuthToken = Token }; 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() .ConfigureWebHost(web => @@ -95,13 +102,24 @@ namespace PolyTrader.Tests.WebUi services.AddSingleton(copyState); services.AddSingleton(logger); 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); }); web.Configure(app => app.UsePolyTraderWebPipeline()); }) .StartAsync(); - return new WebUiTestHost(host, state, copyState, logger, jobs, options); + return new WebUiTestHost(host, state, copyState, logger, jobs, options, settingsDirectory); } /// CopyTrading-Repositories auf einer eigenen InMemory-Datenbank. @@ -158,6 +176,7 @@ namespace PolyTrader.Tests.WebUi Anonymous.Dispose(); await _host.StopAsync(); _host.Dispose(); + try { System.IO.Directory.Delete(_settingsDirectory, recursive: true); } catch { /* temporaer, darf liegen bleiben */ } } } }