R2: Generic Host + Modul-Vertrag + Shell-UI (PolytraderSharp-Konzept)

- Core/Modularity: neuer IModule (Name, DbPrefix, RegisterServices(services,config),
  RegisterUi(host,sp), StartAsync/StopAsync, GetActivationBlocker) + ModuleView
  + IModuleUiHost + WindowMenu. Alte IModule/ModuleRegistry/WindowManager/ModuleFormBase entfernt.
- Program.cs: Host.CreateDefaultBuilder + IConfiguration (appsettings.json/.Local.json);
  Core-Services registriert, Module via RegisterServices, Views via RegisterUi.
- UI: ShellUiHost (Einzelinstanz-Fenster + Fenster-Menue), LauncherForm als Shell
  (Buttons je View), Core-Views Logs/Settings/Workers als eigene Fenster.
- CongressTrading auf neuen Vertrag; Worker als IWorker registriert.
- --smoke-ui Headless-Test (konstruiert jede View + Launcher).
- appsettings.Local.json gitignored.
- Tests angepasst -> 30/30 gruen; Build + Smoke-UI + App-Start verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-28 00:24:00 +02:00
parent c20538c8d8
commit 8f44c0fd82
25 changed files with 695 additions and 1047 deletions
+99 -168
View File
@@ -1,224 +1,155 @@
using IBKRTrader.Core.Database.Migrations;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modules;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Workers;
using IBKRTrader.UI;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader;
/// <summary>
/// Launcher das Basis-Fenster der Anwendung.
/// Enthält die Core-Panels (Workers, Logs, Settings) und eine Modul-Liste,
/// aus der jedes Modul als eigenständiges Fenster geöffnet wird.
/// Launcher das Basis-Fenster (Shell). Zeigt je registrierter View einen Button, führt beim Start
/// Migrationen aus, startet Module und die WorkerEngine, und trägt das gemeinsame Fenster-Menü.
/// Die inhaltlichen Ansichten (Logs, Settings, Workers, Module) sind eigenständige Fenster.
/// </summary>
public partial class LauncherForm : Form
public sealed class LauncherForm : Form
{
private readonly LoggingService _logger;
private readonly WorkerEngine _workerEngine;
private readonly SettingsService _settings;
private readonly CoreMigrations _migrations;
private readonly IBKRMigrations _ibkrMigrations;
private readonly ModuleRegistry _modules;
private readonly WindowManager _windows;
private readonly IServiceProvider _services;
private readonly ShellUiHost _uiHost;
private readonly IServiceProvider _services;
private readonly LoggingService _logger;
private readonly WorkerEngine _workerEngine;
private readonly IReadOnlyList<IModule> _modules;
private LogPanelController? _logPanel;
public LauncherForm(
LoggingService logger,
WorkerEngine workerEngine,
SettingsService settings,
CoreMigrations migrations,
IBKRMigrations ibkrMigrations,
ModuleRegistry modules,
WindowManager windows,
IServiceProvider services)
private readonly Dictionary<string, Button> _viewButtons = new(StringComparer.OrdinalIgnoreCase);
private readonly FlowLayoutPanel _buttonPanel = new()
{
InitializeComponent();
Dock = DockStyle.Fill, Padding = new Padding(16), AutoScroll = true
};
private readonly ToolStripStatusLabel _status = new("Start...");
_logger = logger;
_workerEngine = workerEngine;
_settings = settings;
_migrations = migrations;
_ibkrMigrations = ibkrMigrations;
_modules = modules;
_windows = windows;
_services = services;
public LauncherForm(ShellUiHost uiHost, IServiceProvider services)
{
_uiHost = uiHost;
_services = services;
_logger = services.GetRequiredService<LoggingService>();
_workerEngine = services.GetRequiredService<WorkerEngine>();
_modules = services.GetServices<IModule>().ToList();
Text = "IBKRTrader — Launcher";
Width = 720;
Height = 540;
StartPosition = FormStartPosition.CenterScreen;
_uiHost.SetMainWindow(this);
BuildUi();
_uiHost.OpenStateChanged += UpdateButtonStates;
}
// ─── Form-Events ──────────────────────────────────────────────────────────
private void BuildUi()
{
var menu = new MenuStrip { Dock = DockStyle.Top, ImageScalingSize = new Size(24, 24) };
WindowMenu.Wire(menu, _uiHost, null);
foreach (var view in _uiHost.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
var id = view.Id;
var btn = new Button
{
Text = view.Title, Width = 210, Height = 48, Margin = new Padding(8),
TextAlign = ContentAlignment.MiddleLeft, Image = view.Icon,
ImageAlign = ContentAlignment.MiddleLeft, TextImageRelation = TextImageRelation.ImageBeforeText
};
btn.Click += (_, _) => _uiHost.OpenView(id);
_viewButtons[id] = btn;
_buttonPanel.Controls.Add(btn);
}
var statusStrip = new StatusStrip();
statusStrip.Items.Add(_status);
// Reihenfolge: Fill-Panel zuerst, dann Bottom, dann Top (WinForms Dock-Stacking).
Controls.Add(_buttonPanel);
Controls.Add(statusStrip);
Controls.Add(menu);
MainMenuStrip = menu;
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
InitializeLogPanel();
InitializeWorkerList();
InitializeSettingsGrid();
InitializeModulePanel();
_ = StartupAsync();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
base.OnFormClosing(e);
// Offene Modul-Fenster schließen, dann Worker sauber beenden.
_windows.CloseAll();
_workerEngine.StopAllAsync().GetAwaiter().GetResult();
}
// ─── Initialisierung ──────────────────────────────────────────────────────
private void InitializeLogPanel()
{
// RichTextBox auf Dock.Fill setzen
rtb_logs.Dock = DockStyle.Fill;
_logPanel = new LogPanelController(rtb_logs, _logger);
// LogLevel aus Settings setzen
var levelStr = _settings.Settings.Logging.Level;
if (Enum.TryParse<AppLogLevel>(levelStr, true, out var level))
_logger.SetMinLevel(level);
}
private void InitializeWorkerList()
{
WorkerListBindingSource.Setup(dgv_workerlist, _workerEngine.WorkerInfos);
}
private void InitializeSettingsGrid()
{
pg_settings.SelectedObject = _settings.Settings;
}
/// <summary>Baut je Modul eine Karte mit „Fenster öffnen"-Button.</summary>
private void InitializeModulePanel()
{
flp_modules.FlowDirection = FlowDirection.LeftToRight;
flp_modules.WrapContents = true;
flp_modules.Controls.Clear();
foreach (var module in _modules.Modules)
flp_modules.Controls.Add(BuildModuleCard(module));
}
private Control BuildModuleCard(IModule module)
{
var card = new Panel
// Auch das Schließen-X läuft über die Sicherheitsabfrage.
if (!_uiHost.ShutdownConfirmed)
{
Width = 320,
Height = 150,
Margin = new Padding(8),
BorderStyle = BorderStyle.FixedSingle
};
e.Cancel = true;
BeginInvoke((Action)(() => _uiHost.RequestShutdown()));
return;
}
var title = new Label
{
Text = $"{module.DisplayName} [{module.Key}]",
Font = new Font(Font.FontFamily, 11f, FontStyle.Bold),
Location = new Point(10, 10),
AutoSize = true
};
var desc = new Label
{
Text = module.Description,
Location = new Point(10, 42),
Size = new Size(300, 60),
AutoEllipsis = true
};
var open = new Button
{
Text = "Fenster öffnen",
Location = new Point(10, 108),
Width = 140,
Tag = module
};
open.Click += (_, _) => OpenModuleWindow(module);
var version = new Label
{
Text = $"v{module.Version}",
Location = new Point(240, 113),
AutoSize = true,
ForeColor = SystemColors.GrayText
};
card.Controls.Add(title);
card.Controls.Add(desc);
card.Controls.Add(open);
card.Controls.Add(version);
return card;
}
/// <summary>Öffnet (oder fokussiert) das eigenständige Fenster eines Moduls.</summary>
private void OpenModuleWindow(IModule module)
{
_uiHost.CloseAllViews();
try
{
_windows.OpenOrFocus(module.Key, () => module.CreateWindow(_services));
foreach (var module in _modules)
module.StopAsync(default).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.Error(module.Key, $"{module.DisplayName}: Fenster konnte nicht geöffnet werden.", ex);
MessageBox.Show(this,
$"Fenster für '{module.DisplayName}' konnte nicht geöffnet werden:\n{ex.Message}",
"Modul-Fenster", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
catch { /* Shutdown darf nicht am Modul scheitern */ }
_workerEngine.StopAllAsync().GetAwaiter().GetResult();
// ─── Async Startup ────────────────────────────────────────────────────────
base.OnFormClosing(e);
}
private async Task StartupAsync()
{
_logger.Info("Core", "=== IBKRTrader startet ===");
_logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}");
// DB-Verbindung testen und Core-Migrationen laufen lassen
// Log-Level aus Settings.
var levelStr = _services.GetRequiredService<SettingsService>().Settings.Logging.Level;
if (Enum.TryParse<AppLogLevel>(levelStr, true, out var level))
_logger.SetMinLevel(level);
SetStatus("Migrationen...");
try
{
_logger.Info("Core", "Verbinde mit Datenbank...");
await _migrations.RunAsync();
await _ibkrMigrations.RunAsync();
await _services.GetRequiredService<CoreMigrations>().RunAsync();
await _services.GetRequiredService<IBKRMigrations>().RunAsync();
}
catch (Exception ex)
{
_logger.Error("Core", "Core-Datenbankfehler beim Start.", ex);
}
// Modul-Migrationen: über die Registry iterieren (Core kennt kein Modul).
foreach (var module in _modules.Modules)
foreach (var module in _modules)
{
try
{
await module.InitializeAsync(_services);
}
catch (Exception ex)
{
_logger.Error(module.Key, $"{module.DisplayName}: Initialisierung fehlgeschlagen.", ex);
}
try { await module.StartAsync(default); }
catch (Exception ex) { _logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
}
// Worker starten
await _workerEngine.StartAllAsync();
_logger.Info("Core", "IBKRTrader bereit.");
UpdateStatusBar("Bereit");
SetStatus("Bereit");
UpdateButtonStates();
}
// ─── StatusBar ────────────────────────────────────────────────────────────
private void UpdateStatusBar(string text)
private void UpdateButtonStates()
{
if (statusStrip1.InvokeRequired)
statusStrip1.BeginInvoke(() => UpdateStatusBar(text));
else
{
// Vorhandenes Label nutzen oder neues anlegen
if (statusStrip1.Items.Count == 0)
statusStrip1.Items.Add(new ToolStripStatusLabel());
statusStrip1.Items[0].Text = $"Status: {text} | {DateTime.Now:HH:mm:ss}";
}
if (IsDisposed) return;
if (InvokeRequired) { BeginInvoke((Action)UpdateButtonStates); return; }
foreach (var (id, btn) in _viewButtons)
btn.Font = new Font(btn.Font, _uiHost.IsOpen(id) ? FontStyle.Bold : FontStyle.Regular);
}
private void SetStatus(string text)
{
if (IsDisposed) return;
if (InvokeRequired) { BeginInvoke((Action)(() => SetStatus(text))); return; }
_status.Text = $"Status: {text} | {DateTime.Now:HH:mm:ss}";
}
}