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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1148,7 +1148,11 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d
|
||||
|
||||
<!-- VIEW: MODULES -->
|
||||
<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="card"><div class="card-title">Lade Module …</div></div>
|
||||
</div>
|
||||
|
||||
@@ -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 = `<div style="margin-top: 14px;"><button class="btn btn-secondary btn-sm" onclick="switchView('copytrading')">CopyTrading öffnen →</button></div>`;
|
||||
} 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>`;
|
||||
} else if (m.name.toLowerCase() === 'supervisor') {
|
||||
actionBtn = `<div style="margin-top: 14px;"><button class="btn btn-secondary btn-sm" onclick="switchView('supervisor')">AI Supervisor öffnen →</button></div>`;
|
||||
} 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 view = views[m.name.toLowerCase()];
|
||||
const openBtn = view && m.isRunning
|
||||
? `<button class="btn btn-secondary btn-sm" onclick="switchView(${jsArg(view[0])})">${escapeHtml(view[1])}</button>`
|
||||
: '';
|
||||
// #560: ausdruecklich der gewuenschte Zustand, nicht "umschalten".
|
||||
const toggleBtn = m.canToggle
|
||||
? `<button class="btn ${m.desiredEnabled ? 'btn-danger' : 'btn-primary'} btn-sm" onclick="setModuleEnabled(${jsArg(m.name)}, ${m.desiredEnabled ? 'false' : 'true'})">${escapeHtml(m.action)}</button>`
|
||||
: '';
|
||||
const statusColor = m.blocker ? 'var(--warning)' : (m.isRunning ? 'var(--positive)' : 'var(--muted)');
|
||||
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<span>${escapeHtml(m.name)}</span>
|
||||
<span style="font-size: 12px; color: ${m.isActive ? 'var(--positive)' : 'var(--muted)'}; font-weight: normal;">
|
||||
● ${m.isActive ? 'Aktiv im Betrieb' : 'Deaktiviert'}
|
||||
</span>
|
||||
<span style="font-size: 12px; color: ${statusColor}; font-weight: normal;">● ${escapeHtml(m.status)}</span>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: var(--muted);">
|
||||
${m.isActive ? 'Modul läuft planmäßig im Hintergrund.' : 'Nicht geladen (in den Servereinstellungen oder über DisabledModules deaktiviert).'}
|
||||
</div>
|
||||
${actionBtn}
|
||||
<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>
|
||||
<div style="margin-top: 14px; display: flex; gap: 8px; flex-wrap: wrap;">${toggleBtn}${openBtn}</div>
|
||||
</div>
|
||||
`;
|
||||
}).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
|
||||
|
||||
Reference in New Issue
Block a user