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