feat: Web-UI als zweiter Ausgabeweg neben Avalonia-Shell (#490)
- 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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user