feat: Web-UI als zweiter Ausgabeweg neben Avalonia-Shell (#490)
Build & Test / build (ubuntu-latest) (push) Canceled after 0s
Build & Test / build (windows-latest) (push) Canceled after 0s

- Einheitlicher WebserverWorker in IBKRTrader.Hosting auf Kestrel-Basis
- Token-Authentifizierung (AuthService) mit zeitkonstantem Vergleich und HMAC-Session-Tokens
- OrderManager im Core fuer Bestands- und Reservierungsabgleich offener Broker-Orders
- SettingsSchema fuer reflexionsbasiertes Auslesen und sichere Aktualisierung ohne Klartext-Passwoerter
- SSE-Streaming (LogStreamService) fuer Echtzeit-Logs ueber isolierte Channels
- Statische Single-Page-Application in wwwroot (Avalonia Fluent / Polytrader Theme) fuer alle 9 Ansichten
- Unit-Tests fuer OrderManager, AuthService, SettingsSchema, LogStreamService und WebserverWorker (498 Tests gruen)
This commit is contained in:
Richard
2026-09-06 19:55:40 +02:00
parent 7299148dc4
commit f923721784
35 changed files with 4856 additions and 370 deletions
+181
View File
@@ -0,0 +1,181 @@
using System.Globalization;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Persistence.Entities;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Core.Orders;
/// <summary>
/// Eine Order im Verwaltungsüberblick: Broker-Auftrag angereichert um die zugehörige Kapitalbindung.
/// </summary>
public sealed record ManagedOrder(
BrokerOrder BrokerOrder,
long? ReservationId,
string? Book,
bool IsBackedByReservation);
/// <summary>
/// Überblick über das Orderbuch des Brokers und die eigenen Bindungen.
/// </summary>
public sealed record OrderOverview(
IReadOnlyList<ManagedOrder> Orders,
IReadOnlyList<CoreReservation> Reservations,
IReadOnlyList<ManagedOrder> UnbackedOrders,
bool BrokerReachable);
/// <summary>
/// Ergebnis einer Order-Stornierung.
/// </summary>
public readonly record struct CancelOrderResult(bool Success, string Message);
/// <summary>
/// Ergebnis eines Sammelstornos aller offenen Orders.
/// </summary>
public readonly record struct CancelAllResult(int Cancelled, int Total, bool Success, string Message);
/// <summary>
/// Orderverwaltung (#488): Sehen, was beim Broker liegt, Abgleich gegen eigene Bindungen
/// und kontrolliertes Stornieren (Einzel- und Sammelstorno).
///
/// <para>Vorbild <see cref="Positions.PositionManager"/>: Fachlichkeit lebt im Core-Dienst,
/// damit Shell, Daemon-CLI und Web-API dasselbe Verhalten teilen.</para>
/// </summary>
public sealed class OrderManager
{
private const string LogModule = "Orders";
private readonly IBrokerOrderBook _orderBook;
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly SettingsService _settings;
private readonly LoggingService _logger;
public OrderManager(
IBrokerOrderBook orderBook,
IDbContextFactory<CoreDbContext> dbf,
SettingsService settings,
LoggingService logger)
{
_orderBook = orderBook;
_dbf = dbf;
_settings = settings;
_logger = logger;
}
/// <summary>
/// Ermittelt alle offenen Orders des Brokers und gleicht sie gegen unsere aktiven Bindungen ab.
/// </summary>
public async Task<OrderOverview> GetOrderOverviewAsync(CancellationToken ct = default)
{
IReadOnlyList<BrokerOrder> brokerOrders;
var reachable = false;
try
{
brokerOrders = await _orderBook.GetOpenOrdersAsync(ct).ConfigureAwait(false);
// Wenn der Adapter eine Antwort liefert (auch leer), aber keine Exception wirft,
// prüfen wir, ob die Broker-Verbindung grundsätzlich aktiv ist.
reachable = brokerOrders.Count > 0 || _settings.Settings.IBKR.UseTwsApi;
}
catch (Exception ex)
{
_logger.Warn(LogModule, $"Orderbuch konnte nicht abgerufen werden: {ex.Message}");
brokerOrders = Array.Empty<BrokerOrder>();
}
await using var db = await _dbf.CreateDbContextAsync(ct).ConfigureAwait(false);
var reservations = await db.Reservations
.Where(r => r.State == ReservationState.Reserved || r.State == ReservationState.Submitted)
.OrderBy(r => r.Id)
.ToListAsync(ct)
.ConfigureAwait(false);
var reservationMap = reservations
.Where(r => !string.IsNullOrWhiteSpace(r.BrokerOrderId))
.ToDictionary(r => r.BrokerOrderId!, StringComparer.Ordinal);
var managedOrders = new List<ManagedOrder>(brokerOrders.Count);
var unbacked = new List<ManagedOrder>();
foreach (var bo in brokerOrders)
{
var key = bo.OrderId.ToString(CultureInfo.InvariantCulture);
var isBacked = reservationMap.TryGetValue(key, out var res);
var item = new ManagedOrder(
BrokerOrder: bo,
ReservationId: res?.Id,
Book: res?.Book,
IsBackedByReservation: isBacked);
managedOrders.Add(item);
if (!isBacked) unbacked.Add(item);
}
return new OrderOverview(
Orders: managedOrders,
Reservations: reservations,
UnbackedOrders: unbacked,
BrokerReachable: reachable);
}
/// <summary>
/// Storniert eine einzelne Order beim Broker.
/// </summary>
public async Task<CancelOrderResult> CancelOrderAsync(int orderId, CancellationToken ct = default)
{
try
{
var ok = await _orderBook.CancelOrderAsync(orderId, ct).ConfigureAwait(false);
var text = ok
? string.Create(CultureInfo.InvariantCulture, $"Order {orderId} zurückgenommen.")
: string.Create(CultureInfo.InvariantCulture, $"Order {orderId} NICHT bestätigt zurückgenommen – sie kann weiterhin liegen.");
if (ok) _logger.Warn(LogModule, text);
else _logger.Error(LogModule, text);
return new CancelOrderResult(ok, text);
}
catch (Exception ex)
{
var msg = string.Create(CultureInfo.InvariantCulture, $"Stornieren von Order {orderId} fehlgeschlagen: {ex.Message}");
_logger.Error(LogModule, msg, ex);
return new CancelOrderResult(false, msg);
}
}
/// <summary>
/// Storniert alle offenen Orders beim Broker – streng beschränkt auf den Paper-Modus.
/// </summary>
public async Task<CancelAllResult> CancelAllOrdersAsync(CancellationToken ct = default)
{
var trading = _settings.Settings.Trading;
if (trading.ParsedMode != TradingMode.Paper ||
!string.Equals(trading.Mode?.Trim(), nameof(TradingMode.Paper), StringComparison.OrdinalIgnoreCase))
{
var msg = $"ABBRUCH: Handelsmodus \"{trading.Mode}\" ist nicht eindeutig Paper. " +
"Ein Sammelstorno gibt es nur auf dem Papierkonto.";
_logger.Warn(LogModule, msg);
return new CancelAllResult(0, 0, false, msg);
}
var orders = await _orderBook.GetOpenOrdersAsync(ct).ConfigureAwait(false);
if (orders.Count == 0)
return new CancelAllResult(0, 0, true, "Keine offenen Orders gemeldet – nichts zurückzunehmen.");
var done = 0;
foreach (var o in orders)
{
if (await _orderBook.CancelOrderAsync(o.OrderId, ct).ConfigureAwait(false))
done++;
}
var text = string.Create(CultureInfo.InvariantCulture,
$"{done} von {orders.Count} zurückgenommen.");
_logger.Warn(LogModule, text);
return new CancelAllResult(done, orders.Count, done == orders.Count, text);
}
}
+17 -4
View File
@@ -151,17 +151,30 @@ public class LoggingSettings
[TypeConverter(typeof(ExpandableObjectConverter))]
public class WebserverSettings
{
[Category("Webserver")]
[DisplayName("Aktiviert")]
[Description("Webserver und REST-API beim Start aktivieren")]
public bool Enabled { get; set; } = false;
[Category("Webserver")]
[DisplayName("Port")]
[Description("Port des internen Webservers (Web-UI)")]
public int Port { get; set; } = 5001;
[Category("Webserver")]
[DisplayName("Aktiviert")]
[Description("Webserver und REST-API beim Start aktivieren")]
public bool Enabled { get; set; } = false;
[DisplayName("Netzwerkzugriff erlauben")]
[Description("Wenn aktiviert, lauscht der Server auf allen Schnittstellen (0.0.0.0), sonst nur lokal (127.0.0.1)")]
public bool AllowRemoteAccess { get; set; } = true;
public override string ToString() => Enabled ? $"Aktiv auf Port {Port}" : "Deaktiviert";
[Category("Webserver")]
[DisplayName("Auth-Token")]
[Description("Geheimer Token für den Zugriff auf das Web-UI und die REST-API")]
[PasswordPropertyText(true)]
public string AuthToken { get; set; } = "";
public override string ToString() => Enabled
? $"Aktiv auf Port {Port} ({(AllowRemoteAccess ? "Netzwerk" : "Lokal")})"
: "Deaktiviert";
}
// ─── Workers ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,236 @@
using System.ComponentModel;
using System.Globalization;
using System.Reflection;
namespace IBKRTrader.Core.Settings;
/// <summary>
/// Beschreibung eines einzelnen bearbeitbaren Einstellungsfelds für Oberflächen (Desktop & Web).
/// </summary>
public sealed class SettingsFieldInfo
{
public required string Path { get; init; }
public required string DisplayName { get; init; }
public required string Description { get; init; }
public required Type ValueType { get; init; }
public required string TypeName { get; init; }
public required bool IsPassword { get; init; }
public string[]? EnumValues { get; init; }
public required Func<object?> Get { get; init; }
public required Action<object?> Set { get; init; }
}
/// <summary>
/// Ein logischer Abschnitt von Einstellungen (z. B. "Trading", "Webserver", "Datenbank").
/// </summary>
public sealed record SettingsSectionInfo(string Title, string Key, IReadOnlyList<SettingsFieldInfo> Fields);
/// <summary>
/// DTO-Struktur für die Web-API: Serialisierbare Repräsentation eines Felds.
/// Geheimnisse (Passwörter/Tokens) werden niemals lesend herausgegeben.
/// </summary>
public sealed record SettingsFieldDto(
string Path,
string DisplayName,
string Description,
string TypeName,
bool IsPassword,
bool HasValue,
object? Value,
string[]? EnumValues);
/// <summary>
/// DTO-Struktur für die Web-API: Serialisierbare Repräsentation eines Abschnitts.
/// </summary>
public sealed record SettingsSectionDto(
string Title,
string Key,
IReadOnlyList<SettingsFieldDto> Fields);
/// <summary>
/// Erstellt strukturierte Einstellungsmetadaten aus den Attributen der <see cref="AppSettings"/>.
/// Geteilt zwischen Desktop (Avalonia) und Web (Minimal API), damit beide exakt dieselbe
/// Struktur und dieselben Validierungsregeln verwenden.
/// </summary>
public static class SettingsSchema
{
private static bool IsLeaf(Type t) =>
t == typeof(string) || t.IsEnum ||
t == typeof(int) || t == typeof(long) || t == typeof(double) ||
t == typeof(decimal) || t == typeof(bool);
private static string TypeToName(Type t)
{
if (t == typeof(bool)) return "bool";
if (t.IsEnum) return "enum";
if (t == typeof(int) || t == typeof(long)) return "int";
if (t == typeof(double) || t == typeof(decimal)) return "decimal";
return "string";
}
/// <summary>
/// Analysiert das Konfigurationsobjekt per Reflection und erzeugt die Abschnittsliste.
/// </summary>
public static IReadOnlyList<SettingsSectionInfo> Build(object root)
{
var sections = new List<SettingsSectionInfo>();
Walk(root, prefixTitle: null, prefixKey: null, sections);
return sections;
}
/// <summary>
/// Erzeugt ein sicheres DTO für die Web-API. Passwörter und Tokens werden maskiert.
/// </summary>
public static IReadOnlyList<SettingsSectionDto> ToDto(object root)
{
var sections = Build(root);
return sections.Select(s => new SettingsSectionDto(
Title: s.Title,
Key: s.Key,
Fields: s.Fields.Select(f =>
{
var val = f.Get();
var hasVal = val != null && (!(val is string str) || !string.IsNullOrWhiteSpace(str));
return new SettingsFieldDto(
Path: f.Path,
DisplayName: f.DisplayName,
Description: f.Description,
TypeName: f.TypeName,
IsPassword: f.IsPassword,
HasValue: hasVal,
// Wenn IsPassword true ist, wird der echte Wert niemals herausgegeben!
Value: f.IsPassword ? null : val,
EnumValues: f.EnumValues);
}).ToList())).ToList();
}
/// <summary>
/// Wendet einen vom Web-Client übergebenen Wert auf das passende Feld an.
/// </summary>
public static bool TrySetValue(object root, string path, object? rawValue)
{
var sections = Build(root);
var field = sections.SelectMany(s => s.Fields)
.FirstOrDefault(f => string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase));
if (field == null) return false;
// Wenn ein Passwortfeld leer übergeben wird, bleibt das alte Kennwort unberührt
if (field.IsPassword && (rawValue == null || (rawValue is string s && string.IsNullOrWhiteSpace(s))))
return true;
try
{
if (field.ValueType == typeof(bool))
{
var b = rawValue switch
{
bool boolVal => boolVal,
string strVal => bool.TryParse(strVal, out var parsed) && parsed,
_ => false
};
field.Set(b);
return true;
}
if (field.ValueType.IsEnum)
{
var str = rawValue?.ToString();
if (str != null && Enum.TryParse(field.ValueType, str, true, out var enumVal))
{
field.Set(enumVal);
return true;
}
return false;
}
if (field.ValueType == typeof(int))
{
var num = Convert.ToInt32(rawValue, CultureInfo.InvariantCulture);
field.Set(num);
return true;
}
if (field.ValueType == typeof(decimal))
{
var dec = Convert.ToDecimal(rawValue, CultureInfo.InvariantCulture);
field.Set(dec);
return true;
}
if (field.ValueType == typeof(double))
{
var dbl = Convert.ToDouble(rawValue, CultureInfo.InvariantCulture);
field.Set(dbl);
return true;
}
field.Set(rawValue?.ToString() ?? "");
return true;
}
catch
{
return false;
}
}
private static void Walk(object owner, string? prefixTitle, string? prefixKey, List<SettingsSectionInfo> sections)
{
var fields = new List<SettingsFieldInfo>();
foreach (var prop in owner.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (!prop.CanRead || prop.GetIndexParameters().Length > 0) continue;
var title = prop.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? prop.Name;
var key = prefixKey == null ? prop.Name : $"{prefixKey}.{prop.Name}";
if (IsLeaf(prop.PropertyType))
{
if (!prop.CanWrite) continue;
var target = owner;
var isPassword = prop.GetCustomAttribute<PasswordPropertyTextAttribute>()?.Password == true ||
prop.Name.Contains("Password", StringComparison.OrdinalIgnoreCase) ||
prop.Name.Contains("ApiToken", StringComparison.OrdinalIgnoreCase) ||
prop.Name.Contains("AuthToken", StringComparison.OrdinalIgnoreCase);
fields.Add(new SettingsFieldInfo
{
Path = key,
DisplayName = title,
Description = prop.GetCustomAttribute<DescriptionAttribute>()?.Description ?? "",
ValueType = prop.PropertyType,
TypeName = TypeToName(prop.PropertyType),
IsPassword = isPassword,
EnumValues = prop.PropertyType.IsEnum ? Enum.GetNames(prop.PropertyType) : null,
Get = () => prop.GetValue(target),
Set = v => prop.SetValue(target, v)
});
continue;
}
if (prop.PropertyType.IsClass && prop.PropertyType.Namespace?.StartsWith("IBKRTrader", StringComparison.Ordinal) == true)
{
var child = prop.GetValue(owner);
if (child != null)
{
Walk(child,
prefixTitle == null ? title : $"{prefixTitle} · {title}",
key,
sections);
}
}
}
if (fields.Count > 0)
{
sections.Add(new SettingsSectionInfo(
Title: prefixTitle ?? "Allgemein",
Key: prefixKey ?? "Root",
Fields: fields));
}
}
}
@@ -1,160 +0,0 @@
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Text;
using System.Text.Json;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Positions;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// WebApiService – permanenter REST-API-Service auf Basis von HttpListener.
///
/// <para>Routen: <c>GET /api/status</c>, <c>GET /api/workers</c>, <c>GET /api/positions</c>.</para>
///
/// <para><b>Ausschließlich lesend, und das ist Absicht.</b> Der Listener lauscht auf
/// <c>localhost</c> und kennt keine Anmeldung. Solange das so ist, gehört hierher nichts, was
/// etwas verändert – eine Umbuchung oder eine Sperre über eine offene Schnittstelle wäre ein
/// Eingriff in die Bücher, den niemand belegen kann. Schreibende Endpunkte kommen erst mit
/// Anmeldung und Herkunftsnachweis.</para>
/// </summary>
public class WebApiService : WorkerBase
{
private readonly SettingsService _settings;
private readonly PositionManager _positions;
private WorkerEngine? _engine; // wird nach DI-Aufbau gesetzt (kein readonly)
private HttpListener? _listener;
private static readonly JsonSerializerOptions JsonOpts =
new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public override string Name => "WebApiService";
public override string Module => "Core";
public override WorkerType Type => WorkerType.Service;
protected override TimeSpan? Interval => null;
public WebApiService(LoggingService logger, IDbContextFactory<CoreDbContext> db,
SettingsService settings, PositionManager positions)
: base(logger, db)
{
_settings = settings;
_positions = positions;
Info.Active = settings.Settings.Webserver.Enabled;
Info.RunEvery = "Service";
}
/// <summary>Setzt die WorkerEngine nach DI-Aufbau (zirkulare Abhängigkeit vermeiden).</summary>
public void SetEngine(WorkerEngine engine) => _engine = engine;
protected override async Task ExecuteAsync(CancellationToken ct)
{
var port = _settings.Settings.Webserver.Port + 1; // API auf Port+1
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{port}/api/");
try
{
_listener.Start();
Logger.Info(Module, $"WebAPI gestartet auf http://localhost:{port}/api/");
Info.Info = $"http://localhost:{port}/api/";
while (!ct.IsCancellationRequested)
{
var contextTask = _listener.GetContextAsync();
var cancelTask = Task.Delay(Timeout.Infinite, ct);
var completed = await Task.WhenAny(contextTask, cancelTask);
if (completed == cancelTask) break;
var ctx = await contextTask;
_ = RouteAsync(ctx);
}
}
finally
{
_listener.Stop();
Logger.Info(Module, "WebAPI gestoppt.");
}
}
/// <summary>
/// Der Positionsbestand über alle Bücher (#485).
///
/// <para>Die Broker-Abfrage kostet eine Verbindung und mehrere Sekunden und bleibt deshalb
/// abschaltbar: <c>?broker=0</c> antwortet allein aus der eigenen Buchführung. <c>brokerQuantity</c>
/// ist <c>null</c>, wenn der Broker nichts gemeldet hat – das ist ausdrücklich etwas anderes als
/// null Stück, und ein Client darf daraus keine Abweichung ableiten.</para>
/// </summary>
private async Task<object> PositionsAsync(HttpListenerContext ctx)
{
var includeBroker = !string.Equals(
ctx.Request.QueryString["broker"], "0", StringComparison.Ordinal);
var overview = await _positions.GetOverviewAsync(includeBroker).ConfigureAwait(false);
return new
{
brokerReachable = overview.BrokerReachable,
positions = overview.Positions.Select(p => new
{
book = p.Book.Value,
origin = p.Origin,
instrument = p.Instrument.Key,
currency = p.Instrument.Currency,
quantity = p.Quantity,
avgPrice = p.AvgPrice,
boundCapital = p.BoundCapital,
brokerQuantity = p.BrokerQuantity,
diverges = p.Diverges,
positionLock = p.Lock.ToString(),
blocked = p.Rule.Blocked,
maxQuantity = p.Rule.MaxQuantity
}),
unknownToUs = overview.UnknownToUs.Select(p => new
{
symbol = p.Symbol,
secType = p.SecType,
currency = p.Currency,
quantity = p.Quantity,
avgCost = p.AvgCost
})
};
}
private async Task RouteAsync(HttpListenerContext ctx)
{
try
{
var path = ctx.Request.Url?.AbsolutePath.TrimEnd('/').ToLowerInvariant() ?? "";
object? result = path switch
{
"/api/status" => new { status = "ok", time = DateTime.UtcNow },
"/api/workers" => _engine?.WorkerInfos
.Select(w => new { w.WorkerName, w.Module, w.Type,
w.Info, w.Active }) ?? [],
"/api/positions" => await PositionsAsync(ctx).ConfigureAwait(false),
_ => null
};
if (result == null)
{
ctx.Response.StatusCode = 404;
ctx.Response.Close();
return;
}
var json = JsonSerializer.Serialize(result, JsonOpts);
var bytes = Encoding.UTF8.GetBytes(json);
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.StatusCode = 200;
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
}
catch { }
}
}
@@ -1,82 +0,0 @@
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Text;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// WebserverService – permanenter Service auf Basis von System.Net.HttpListener.
/// Lauscht auf http://localhost:{port}/ und liefert einen Status-JSON.
/// Wird später durch vollständige Web-UI erweitert.
/// </summary>
public class WebserverService : WorkerBase
{
private readonly SettingsService _settings;
private HttpListener? _listener;
public override string Name => "WebserverService";
public override string Module => "Core";
public override WorkerType Type => WorkerType.Service;
protected override TimeSpan? Interval => null; // Service = permanent
public WebserverService(LoggingService logger, IDbContextFactory<CoreDbContext> db, SettingsService settings)
: base(logger, db)
{
_settings = settings;
Info.Active = settings.Settings.Webserver.Enabled;
Info.RunEvery = "Service";
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var port = _settings.Settings.Webserver.Port;
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{port}/");
try
{
_listener.Start();
Logger.Info(Module, $"Webserver gestartet auf http://localhost:{port}/");
Info.Info = $"http://localhost:{port}/";
while (!ct.IsCancellationRequested)
{
// GetContextAsync blockiert – abbrechen via ct
var contextTask = _listener.GetContextAsync();
var cancelTask = Task.Delay(Timeout.Infinite, ct);
var completed = await Task.WhenAny(contextTask, cancelTask);
if (completed == cancelTask) break;
var ctx = await contextTask;
_ = HandleRequestAsync(ctx); // fire-and-forget pro Request
}
}
finally
{
_listener.Stop();
Logger.Info(Module, "Webserver gestoppt.");
}
}
private async Task HandleRequestAsync(HttpListenerContext ctx)
{
try
{
const string json = """{"status":"ok","app":"IBKRTrader","version":"1.0.0"}""";
var bytes = Encoding.UTF8.GetBytes(json);
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.StatusCode = 200;
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
}
catch { /* Client hat getrennt */ }
}
}