diff --git a/Program.cs b/Program.cs index d50b8f9..6c52dc6 100644 --- a/Program.cs +++ b/Program.cs @@ -27,6 +27,9 @@ internal static class Program { public static IHost? AppHost { get; private set; } + /// Pfad der (gitignorierten) Server-Settings-Datei – Quelle u.a. für die Modul-Aktivierung. + private const string ServerSettingsPath = "server_settings.xml"; + [STAThread] private static void Main(string[] args) { @@ -62,13 +65,21 @@ internal static class Program ApplicationConfiguration.Initialize(); - var modules = new System.Collections.Generic.List + // Server-Settings früh laden (VOR der DI-Registrierung), damit deaktivierte Module gar nicht + // erst geladen werden. Dieselbe Instanz wird als Singleton weitergereicht (eine Quelle). + var serverSettings = PolyTraderSharp.Models.ServerSettings.Load(ServerSettingsPath); + + // Alle bekannten Module; „modules" enthält nur die aktiven (nicht in DisabledModules). + var allModules = new System.Collections.Generic.List { new CopyTradingModule(), new ResolutionFarmingModule(), new SupervisorModule(), new AccountingModule() }; + var disabledModules = new System.Collections.Generic.HashSet( + serverSettings.DisabledModules, StringComparer.OrdinalIgnoreCase); + var modules = allModules.Where(m => !disabledModules.Contains(m.Name)).ToList(); AppHost = Host.CreateDefaultBuilder() // Config immer neben der EXE suchen (nicht im Arbeitsverzeichnis), damit die App @@ -87,7 +98,7 @@ internal static class Program }; services.Configure(context.Configuration.GetSection(DatabaseOptions.SectionName)); services.AddCorePersistence(databaseOptions); - services.AddSingleton((IServiceProvider sp) => ServerSettings.Load("server_settings.xml")); + services.AddSingleton(serverSettings); services.AddSingleton(); services.AddSingleton(delegate(IServiceProvider sp) { @@ -148,10 +159,14 @@ internal static class Program try { // Trade-Nummerierung fortsetzen: höchste bestehende TradeId serverseitig lesen - // (M3: kein Full-Table-Load mehr über Find(_=>true).Max()). - var copyState = AppHost.Services.GetRequiredService(); - var tradeLog = AppHost.Services.GetRequiredService(); - copyState.TotalCopyTrades = tradeLog.GetMaxTradeId(); + // (M3: kein Full-Table-Load mehr über Find(_=>true).Max()). Nur wenn CopyTrading aktiv ist – + // sonst sind dessen Services (CopyTradingState/…) gar nicht registriert. + if (modules.Any(m => m is CopyTradingModule)) + { + var copyState = AppHost.Services.GetRequiredService(); + var tradeLog = AppHost.Services.GetRequiredService(); + copyState.TotalCopyTrades = tradeLog.GetMaxTradeId(); + } } catch (Exception ex) { @@ -223,9 +238,17 @@ internal static class Program CreateForm = () => { var view = new PolyTraderSharp.Ui.Views.DashboardView(); + var config = viewServices.GetRequiredService(); + // Alle Module (auch deaktivierte) fürs Dashboard: läuft-Status = ist in dieser Session geladen. + var moduleInfos = allModules + .Select(m => new PolyTraderSharp.Ui.Views.ModuleActivationInfo( + m.Name, modules.Contains(m), m.GetActivationBlocker(config))) + .ToList(); view.Initialize( viewServices.GetRequiredService(), - viewServices.GetRequiredService()); + viewServices.GetRequiredService(), + moduleInfos, + ServerSettingsPath); return view; } }); @@ -235,11 +258,37 @@ internal static class Program module.RegisterUi(uiHost, viewServices); } + // Menü-Icons zentral zuweisen: Die App kennt alle Fenster-Ressourcen; Core und Module + // referenzieren sie nicht. So erscheint jedes Fenster mit Icon in der obersten Menüleiste. + AssignMenuIcons(uiHost); + var launcher = AppHost.Services.GetRequiredService(); Application.Run(launcher); AppHost.StopAsync().GetAwaiter().GetResult(); } + /// + /// Weist den registrierten Views (Core + Module) ihr Menü-Icon aus den App-Ressourcen zu – + /// gemappt über die stabile View-ID. Bereits gesetzte Icons bleiben erhalten. + /// + private static void AssignMenuIcons(PolyTraderSharp.Ui.ShellUiHost uiHost) + { + var map = new System.Collections.Generic.Dictionary + { + ["core.dashboard"] = Properties.Resources.dashboard, + ["core.settings"] = Properties.Resources.setting_tools, + ["core.terminal"] = Properties.Resources.error_log, + ["core.jobs"] = Properties.Resources.system_time, + ["accounting.main"] = Properties.Resources.coins_in_hand, + ["copytrading.main"] = Properties.Resources.cross_reference, + ["resolutionfarming.main"] = Properties.Resources.file_start_workflow, + ["supervisor.main"] = Properties.Resources.emotion_batman, + }; + foreach (var view in uiHost.Views) + if (view.Icon == null && map.TryGetValue(view.Id, out var img)) + view.Icon = img; + } + private static void RunConfigMigrationFromJson(string folder) { var config = new Microsoft.Extensions.Configuration.ConfigurationBuilder() diff --git a/Ui/LauncherForm.Designer.cs b/Ui/LauncherForm.Designer.cs index ac38dc8..c51d554 100644 --- a/Ui/LauncherForm.Designer.cs +++ b/Ui/LauncherForm.Designer.cs @@ -18,7 +18,6 @@ namespace PolyTraderSharp.Ui private void InitializeComponent() { menuStrip = new MenuStrip(); - miFenster = new ToolStripMenuItem(); toolstrip_windows = new ToolStrip(); btn_dashboard = new ToolStripButton(); btn_settings = new ToolStripButton(); @@ -56,19 +55,13 @@ namespace PolyTraderSharp.Ui // menuStrip // menuStrip.ImageScalingSize = new Size(24, 24); - menuStrip.Items.AddRange(new ToolStripItem[] { miFenster }); + // Einträge werden zur Laufzeit über WindowMenu.Wire gefüllt (alle Fenster nebeneinander mit Icon). menuStrip.Location = new Point(0, 0); menuStrip.Name = "menuStrip"; menuStrip.Padding = new Padding(9, 3, 0, 3); menuStrip.Size = new Size(2599, 35); menuStrip.TabIndex = 0; - // - // miFenster - // - miFenster.Name = "miFenster"; - miFenster.Size = new Size(85, 29); - miFenster.Text = "Fenster"; - // + // // toolstrip_windows // toolstrip_windows.AutoSize = false; @@ -345,7 +338,6 @@ namespace PolyTraderSharp.Ui #endregion private System.Windows.Forms.MenuStrip menuStrip; - private System.Windows.Forms.ToolStripMenuItem miFenster; private ToolStrip toolstrip_windows; private ToolStripButton btn_settings; private ToolStripButton btn_terminal; diff --git a/Ui/LauncherForm.cs b/Ui/LauncherForm.cs index 38bd3d7..6b56e78 100644 --- a/Ui/LauncherForm.cs +++ b/Ui/LauncherForm.cs @@ -60,8 +60,9 @@ namespace PolyTraderSharp.Ui btn.Enabled = false; // Modul/View nicht verfügbar } - // Gemeinsames „Fenster"-Menü (Launcher ist das aktuelle Fenster → currentViewId null). - PolyTrader.Core.Modularity.WindowMenu.Wire(miFenster, _uiHost, null); + // Gemeinsame Fenster-Menüleiste: alle Fenster nebeneinander mit Icon (Launcher ist das + // aktuelle Fenster → currentViewId null). + PolyTrader.Core.Modularity.WindowMenu.Wire(menuStrip, _uiHost, null); btn_liveTrading.Click += (_, _) => CycleLiveTrading(); btn_demoTrading.Click += (_, _) => CycleDemoTrading(); @@ -86,6 +87,22 @@ namespace PolyTraderSharp.Ui UpdateWindowButtonStates(); } + /// + /// Fängt das Schließen des Launchers ab: Statt direkt zu beenden, läuft auch das Schließen-X + /// über die Sicherheitsabfrage (siehe ). Erst wenn dort + /// bestätigt wurde (), darf das Fenster schließen. + /// + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (!_uiHost.ShutdownConfirmed) + { + e.Cancel = true; + BeginInvoke((Action)(() => _uiHost.RequestShutdown())); + return; + } + base.OnFormClosing(e); + } + private void UpdateWindowButtonStates() { if (IsDisposed) return; diff --git a/Ui/ShellUiHost.cs b/Ui/ShellUiHost.cs index cc888b1..a473857 100644 --- a/Ui/ShellUiHost.cs +++ b/Ui/ShellUiHost.cs @@ -17,6 +17,13 @@ namespace PolyTraderSharp.Ui private readonly List _views = new(); private readonly Dictionary _open = new(); private Form? _mainWindow; + private bool _shutdownDialogOpen; + + /// + /// True, sobald das Herunterfahren über die Sicherheitsabfrage bestätigt wurde. Der Launcher + /// wertet das in FormClosing aus, um beim Schließen-X denselben Dialog zu erzwingen. + /// + public bool ShutdownConfirmed { get; private set; } /// Wird ausgelöst, wenn sich der Offen-Status irgendeiner View ändert. public event Action? OpenStateChanged; @@ -29,15 +36,14 @@ namespace PolyTraderSharp.Ui public void SetMainWindow(Form main) => _mainWindow = main; /// - /// Fügt einem Fenster das gemeinsame „Fenster"-Menü hinzu (oberste Leiste). Als letztes - /// hinzugefügtes Top-Control belegt der MenuStrip die oberste Zeile über etwaigen ToolStrips. + /// Fügt einem Fenster die gemeinsame Fenster-Menüleiste hinzu (oberste Leiste, alle Fenster + /// nebeneinander mit Icon). Als letztes hinzugefügtes Top-Control belegt der MenuStrip die + /// oberste Zeile über etwaigen ToolStrips. /// private void AttachWindowMenu(Form form, string currentViewId) { - var menu = new MenuStrip { Dock = DockStyle.Top }; - var fenster = new ToolStripMenuItem("Fenster"); - menu.Items.Add(fenster); - PolyTrader.Core.Modularity.WindowMenu.Wire(fenster, this, currentViewId); + var menu = new MenuStrip { Dock = DockStyle.Top, ImageScalingSize = new System.Drawing.Size(24, 24) }; + PolyTrader.Core.Modularity.WindowMenu.Wire(menu, this, currentViewId); form.Controls.Add(menu); form.MainMenuStrip = menu; } @@ -54,6 +60,32 @@ namespace PolyTraderSharp.Ui public bool IsOpen(string viewId) => _open.TryGetValue(viewId, out var form) && !form.IsDisposed; + /// + /// Zeigt die Sicherheitsabfrage (Timer-gesperrter Beenden-Button, Abbrechen jederzeit). Bei + /// Bestätigung wird die Message-Loop beendet – das geordnete Herunterfahren der Module läuft + /// anschließend in Program.Main über AppHost.StopAsync() (nach Application.Run). + /// Aus jedem Fenster aufrufbar; der Dialog erscheint zentriert über dem Hauptfenster. + /// + public void RequestShutdown() + { + if (ShutdownConfirmed || _shutdownDialogOpen) return; + _shutdownDialogOpen = true; + try + { + using var dlg = new ShutdownConfirmDialog(); + var owner = _mainWindow != null && !_mainWindow.IsDisposed ? _mainWindow : null; + var result = owner != null ? dlg.ShowDialog(owner) : dlg.ShowDialog(); + if (result != DialogResult.OK) return; + + ShutdownConfirmed = true; + Application.Exit(); // beendet die Message-Loop; StopAsync() fährt Module danach sauber herunter + } + finally + { + _shutdownDialogOpen = false; + } + } + public void OpenView(string viewId) { var view = _views.FirstOrDefault(v => v.Id == viewId); diff --git a/Ui/ShutdownConfirmDialog.Designer.cs b/Ui/ShutdownConfirmDialog.Designer.cs new file mode 100644 index 0000000..fbabb74 --- /dev/null +++ b/Ui/ShutdownConfirmDialog.Designer.cs @@ -0,0 +1,120 @@ +namespace PolyTraderSharp.Ui +{ + partial class ShutdownConfirmDialog + { + private System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Vom Komponenten-Designer generierter Code + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + lblTitle = new System.Windows.Forms.Label(); + lblInfo = new System.Windows.Forms.Label(); + lblCountdown = new System.Windows.Forms.Label(); + btnConfirm = new System.Windows.Forms.Button(); + btnCancel = new System.Windows.Forms.Button(); + countdownTimer = new System.Windows.Forms.Timer(components); + SuspendLayout(); + // + // lblTitle + // + lblTitle.AutoSize = true; + lblTitle.Font = new System.Drawing.Font("Segoe UI", 12F, System.Drawing.FontStyle.Bold); + lblTitle.Location = new System.Drawing.Point(18, 16); + lblTitle.Name = "lblTitle"; + lblTitle.Size = new System.Drawing.Size(300, 28); + lblTitle.TabIndex = 0; + lblTitle.Text = "PolyTrader sicher beenden?"; + // + // lblInfo + // + lblInfo.Location = new System.Drawing.Point(20, 52); + lblInfo.Name = "lblInfo"; + lblInfo.Size = new System.Drawing.Size(464, 96); + lblInfo.TabIndex = 1; + lblInfo.Text = "PolyTrader und alle Module werden geordnet heruntergefahren. Laufende API-Aufrufe, " + + "Order- und Trading-Vorgänge werden dabei sauber abgeschlossen.\r\n\r\n" + + "Aus Sicherheit ist die Beenden-Schaltfläche erst nach Ablauf eines kurzen Timers " + + "aktiv. Abbrechen ist jederzeit möglich."; + // + // lblCountdown + // + lblCountdown.AutoSize = true; + lblCountdown.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Bold); + lblCountdown.ForeColor = System.Drawing.Color.Firebrick; + lblCountdown.Location = new System.Drawing.Point(20, 158); + lblCountdown.Name = "lblCountdown"; + lblCountdown.Size = new System.Drawing.Size(200, 23); + lblCountdown.TabIndex = 2; + lblCountdown.Text = "Beenden möglich in 10 s …"; + // + // btnConfirm + // + btnConfirm.DialogResult = System.Windows.Forms.DialogResult.OK; + btnConfirm.Enabled = false; + btnConfirm.Location = new System.Drawing.Point(232, 196); + btnConfirm.Name = "btnConfirm"; + btnConfirm.Size = new System.Drawing.Size(140, 36); + btnConfirm.TabIndex = 3; + btnConfirm.Text = "Beenden (10 s)"; + btnConfirm.UseVisualStyleBackColor = true; + // + // btnCancel + // + btnCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel; + btnCancel.Location = new System.Drawing.Point(380, 196); + btnCancel.Name = "btnCancel"; + btnCancel.Size = new System.Drawing.Size(104, 36); + btnCancel.TabIndex = 4; + btnCancel.Text = "Abbrechen"; + btnCancel.UseVisualStyleBackColor = true; + // + // countdownTimer + // + countdownTimer.Interval = 1000; + countdownTimer.Tick += CountdownTimer_Tick; + // + // ShutdownConfirmDialog + // + AcceptButton = btnConfirm; + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + CancelButton = btnCancel; + ClientSize = new System.Drawing.Size(504, 248); + Controls.Add(btnCancel); + Controls.Add(btnConfirm); + Controls.Add(lblCountdown); + Controls.Add(lblInfo); + Controls.Add(lblTitle); + FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + Name = "ShutdownConfirmDialog"; + ShowIcon = false; + ShowInTaskbar = false; + StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + Text = "PolyTrader beenden"; + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private System.Windows.Forms.Label lblTitle; + private System.Windows.Forms.Label lblInfo; + private System.Windows.Forms.Label lblCountdown; + private System.Windows.Forms.Button btnConfirm; + private System.Windows.Forms.Button btnCancel; + private System.Windows.Forms.Timer countdownTimer; + } +} diff --git a/Ui/ShutdownConfirmDialog.cs b/Ui/ShutdownConfirmDialog.cs new file mode 100644 index 0000000..8aa28be --- /dev/null +++ b/Ui/ShutdownConfirmDialog.cs @@ -0,0 +1,62 @@ +using System; +using System.Windows.Forms; + +namespace PolyTraderSharp.Ui +{ + /// + /// Sicherheitsabfrage vor dem Herunterfahren. Die Beenden-Schaltfläche ist erst nach Ablauf eines + /// kurzen Timers klickbar (verhindert versehentliches Beenden, z.B. während auf eine API-Antwort + /// gewartet wird); „Abbrechen" ist jederzeit möglich. Bei Bestätigung liefert der Dialog + /// – das eigentliche geordnete Herunterfahren übernimmt der Aufrufer. + /// + public partial class ShutdownConfirmDialog : Form + { + private int _remaining; + + /// Sekunden, bis „Beenden" freigeschaltet wird (Standard 10). + public ShutdownConfirmDialog(int delaySeconds = 10) + { + InitializeComponent(); + _remaining = Math.Max(0, delaySeconds); + UpdateCountdownUi(); + } + + protected override void OnShown(EventArgs e) + { + base.OnShown(e); + if (_remaining <= 0) + { + EnableConfirm(); + return; + } + countdownTimer.Start(); + } + + private void CountdownTimer_Tick(object? sender, EventArgs e) + { + _remaining--; + if (_remaining <= 0) + { + countdownTimer.Stop(); + EnableConfirm(); + return; + } + UpdateCountdownUi(); + } + + private void UpdateCountdownUi() + { + lblCountdown.Text = $"Beenden möglich in {_remaining} s …"; + btnConfirm.Text = $"Beenden ({_remaining} s)"; + } + + private void EnableConfirm() + { + btnConfirm.Enabled = true; + btnConfirm.Text = "Jetzt beenden"; + lblCountdown.Text = "PolyTrader kann jetzt beendet werden."; + lblCountdown.ForeColor = System.Drawing.Color.ForestGreen; + btnConfirm.Focus(); + } + } +} diff --git a/Ui/Views/DashboardView.Designer.cs b/Ui/Views/DashboardView.Designer.cs index 14ea984..04e71ea 100644 --- a/Ui/Views/DashboardView.Designer.cs +++ b/Ui/Views/DashboardView.Designer.cs @@ -58,8 +58,17 @@ namespace PolyTraderSharp.Ui.Views lblErgebnis = new Label(); tbSearch = new TextBox(); lblSuche = new Label(); + tabModules = new TabPage(); + dgvModules = new DataGridView(); + colModName = new DataGridViewTextBoxColumn(); + colModStatus = new DataGridViewTextBoxColumn(); + colModHint = new DataGridViewTextBoxColumn(); + colModAction = new DataGridViewButtonColumn(); + lblModulesInfo = new Label(); toolStripDash.SuspendLayout(); tabControlDash.SuspendLayout(); + tabModules.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)dgvModules).BeginInit(); tabDashboard.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)picEquity).BeginInit(); pnlChartsBottom.SuspendLayout(); @@ -140,6 +149,7 @@ namespace PolyTraderSharp.Ui.Views // tabControlDash.Controls.Add(tabDashboard); tabControlDash.Controls.Add(tabHistory); + tabControlDash.Controls.Add(tabModules); tabControlDash.Dock = DockStyle.Fill; tabControlDash.Location = new Point(0, 34); tabControlDash.Margin = new Padding(4, 5, 4, 5); @@ -452,9 +462,86 @@ namespace PolyTraderSharp.Ui.Views lblSuche.Size = new Size(63, 25); lblSuche.TabIndex = 0; lblSuche.Text = "Suche:"; - // + // + // tabModules + // + tabModules.Controls.Add(dgvModules); + tabModules.Controls.Add(lblModulesInfo); + tabModules.Location = new Point(4, 34); + tabModules.Margin = new Padding(4, 5, 4, 5); + tabModules.Name = "tabModules"; + tabModules.Padding = new Padding(12, 12, 12, 12); + tabModules.Size = new Size(1706, 1000); + tabModules.TabIndex = 2; + tabModules.Text = "Module"; + tabModules.UseVisualStyleBackColor = true; + // + // lblModulesInfo + // + lblModulesInfo.Dock = DockStyle.Top; + lblModulesInfo.Font = new Font("Segoe UI", 10F); + lblModulesInfo.Location = new Point(12, 12); + lblModulesInfo.Name = "lblModulesInfo"; + lblModulesInfo.Padding = new Padding(4, 8, 4, 12); + lblModulesInfo.Size = new Size(1682, 56); + lblModulesInfo.TabIndex = 0; + lblModulesInfo.Text = "Module aktivieren/deaktivieren. Änderungen greifen nach dem nächsten Neustart von PolyTrader."; + // + // dgvModules + // + dgvModules.AllowUserToAddRows = false; + dgvModules.AllowUserToDeleteRows = false; + dgvModules.AllowUserToResizeRows = false; + dgvModules.AutoGenerateColumns = false; + dgvModules.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize; + dgvModules.Columns.AddRange(new DataGridViewColumn[] { colModName, colModStatus, colModHint, colModAction }); + dgvModules.Dock = DockStyle.Fill; + dgvModules.Location = new Point(12, 68); + dgvModules.Margin = new Padding(4, 5, 4, 5); + dgvModules.Name = "dgvModules"; + dgvModules.ReadOnly = true; + dgvModules.RowHeadersVisible = false; + dgvModules.RowHeadersWidth = 62; + dgvModules.SelectionMode = DataGridViewSelectionMode.FullRowSelect; + dgvModules.Size = new Size(1682, 920); + dgvModules.TabIndex = 1; + // + // colModName + // + colModName.DataPropertyName = "Modul"; + colModName.HeaderText = "Modul"; + colModName.Name = "colModName"; + colModName.ReadOnly = true; + colModName.Width = 220; + // + // colModStatus + // + colModStatus.DataPropertyName = "Status"; + colModStatus.HeaderText = "Status"; + colModStatus.Name = "colModStatus"; + colModStatus.ReadOnly = true; + colModStatus.Width = 260; + // + // colModHint + // + colModHint.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; + colModHint.DataPropertyName = "Hinweis"; + colModHint.HeaderText = "Hinweis"; + colModHint.Name = "colModHint"; + colModHint.ReadOnly = true; + // + // colModAction + // + colModAction.DataPropertyName = "Aktion"; + colModAction.HeaderText = "Aktion"; + colModAction.Name = "colModAction"; + colModAction.ReadOnly = true; + colModAction.Text = "Umschalten"; + colModAction.UseColumnTextForButtonValue = false; + colModAction.Width = 160; + // // DashboardView - // + // AutoScaleDimensions = new SizeF(10F, 25F); AutoScaleMode = AutoScaleMode.Font; ClientSize = new Size(1714, 1083); @@ -477,6 +564,8 @@ namespace PolyTraderSharp.Ui.Views ((System.ComponentModel.ISupportInitialize)dgvTrades).EndInit(); pnlHistFilters.ResumeLayout(false); pnlHistFilters.PerformLayout(); + tabModules.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)dgvModules).EndInit(); ResumeLayout(false); PerformLayout(); } @@ -523,5 +612,12 @@ namespace PolyTraderSharp.Ui.Views private System.Windows.Forms.DataGridViewTextBoxColumn colPnl; private System.Windows.Forms.DataGridViewTextBoxColumn colPnlPct; private System.Windows.Forms.DataGridViewTextBoxColumn colClosedAt; + private System.Windows.Forms.TabPage tabModules; + private System.Windows.Forms.Label lblModulesInfo; + private System.Windows.Forms.DataGridView dgvModules; + private System.Windows.Forms.DataGridViewTextBoxColumn colModName; + private System.Windows.Forms.DataGridViewTextBoxColumn colModStatus; + private System.Windows.Forms.DataGridViewTextBoxColumn colModHint; + private System.Windows.Forms.DataGridViewButtonColumn colModAction; } } diff --git a/Ui/Views/DashboardView.cs b/Ui/Views/DashboardView.cs index 3ad6404..570d61f 100644 --- a/Ui/Views/DashboardView.cs +++ b/Ui/Views/DashboardView.cs @@ -22,6 +22,9 @@ namespace PolyTraderSharp.Ui.Views private ITradeLogRepository? _tradeLog; private TradingState? _state; + private IReadOnlyList? _moduleInfos; // alle Module (auch deaktivierte) + private string? _settingsPath; // Quelle/Ziel der Modul-Aktivierung + private List _allTrades = new(); // zuletzt geladener Roh-Satz private List _historyBase = new(); // Scope-gefiltert, Basis für die Historie-Filter private bool _loading; @@ -48,13 +51,24 @@ namespace PolyTraderSharp.Ui.Views cbRange.SelectedIndexChanged += (_, _) => { if (!_loading) ApplyScope(); }; tbSearch.TextChanged += (_, _) => { if (!_loading) ApplyHistoryFilter(); }; cbWinLoss.SelectedIndexChanged += (_, _) => { if (!_loading) ApplyHistoryFilter(); }; + + dgvModules.CellContentClick += Modules_CellContentClick; } - public void Initialize(ITradeLogRepository tradeLog, TradingState state) + /// + /// Alle bekannten Module (auch deaktivierte) für den Tab „Module". null = kein + /// Modul-Management (Tab wird entfernt, z.B. im Headless-Smoke-Test). + /// + /// Pfad der Server-Settings-Datei (Ziel für die Ein/Aus-Persistenz). + public void Initialize(ITradeLogRepository tradeLog, TradingState state, + IReadOnlyList? modules = null, string? settingsPath = null) { _tradeLog = tradeLog; _state = state; + _moduleInfos = modules; + _settingsPath = settingsPath; RefreshData(); + InitModulesTab(); } // ===== Daten laden / Scope ===== @@ -251,8 +265,135 @@ namespace PolyTraderSharp.Ui.Views { public override string ToString() => Label; } + + // ===== Module aktivieren/deaktivieren (neustart-basiert) ===== + + private void InitModulesTab() + { + if (_moduleInfos == null || string.IsNullOrEmpty(_settingsPath)) + { + // Kein Modul-Management (z.B. Smoke-Test) -> Tab entfernen statt leer anzuzeigen. + if (tabControlDash.TabPages.Contains(tabModules)) + tabControlDash.TabPages.Remove(tabModules); + return; + } + PopulateModules(); + } + + private HashSet LoadDisabledModules() + { + try + { + var s = ServerSettings.Load(_settingsPath!); + return new HashSet(s.DisabledModules, StringComparer.OrdinalIgnoreCase); + } + catch + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + } + + private void PopulateModules() + { + if (_moduleInfos == null) return; + var disabled = LoadDisabledModules(); + + var rows = new List(); + 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"; + } + + rows.Add(new ModuleRow { Modul = m.Name, Status = status, Hinweis = hint, Aktion = action }); + } + + dgvModules.DataSource = new BindingList(rows); + } + + private void Modules_CellContentClick(object? sender, DataGridViewCellEventArgs e) + { + if (_moduleInfos == null || string.IsNullOrEmpty(_settingsPath)) return; + if (e.RowIndex < 0 || e.ColumnIndex < 0) return; + if (dgvModules.Columns[e.ColumnIndex].Name != "colModAction") return; + if (dgvModules.Rows[e.RowIndex].DataBoundItem is not ModuleRow row) return; + + var info = _moduleInfos.FirstOrDefault(m => m.Name == row.Modul); + if (info == null) return; + + if (!string.IsNullOrEmpty(info.Blocker)) + { + MessageBox.Show($"Modul „{info.Name}“ kann nicht aktiviert werden:\n\n{info.Blocker}", + "Aktivierung nicht möglich", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + try + { + // Frisch aus der Datei lesen, damit parallele Änderungen (z.B. Settings-Fenster) nicht überschrieben werden. + var settings = ServerSettings.Load(_settingsPath!); + var set = new HashSet(settings.DisabledModules, StringComparer.OrdinalIgnoreCase); + + bool nowDisabled; + if (set.Contains(info.Name)) { set.Remove(info.Name); nowDisabled = false; } + else { set.Add(info.Name); nowDisabled = true; } + + settings.DisabledModules = set.OrderBy(x => x).ToList(); + settings.Save(_settingsPath!); + + PopulateModules(); + + MessageBox.Show( + $"Modul „{info.Name}“ wird beim nächsten Start {(nowDisabled ? "NICHT mehr geladen" : "geladen")}.\n\n" + + "Die Änderung greift erst nach einem Neustart von PolyTrader.", + "Gespeichert", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Konnte die Modul-Einstellung nicht speichern: {ex.Message}", "Fehler", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + /// Anzeige-Zeile des Modul-Grids (Bindung über DataPropertyName). + private sealed class ModuleRow + { + public string Modul { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string Hinweis { get; set; } = string.Empty; + public string Aktion { get; set; } = string.Empty; + } } + /// + /// Aktivierungs-Info eines Moduls fürs Dashboard: Name, ob es in dieser Session läuft und ein + /// optionaler Grund, warum es nicht aktivierbar ist (z.B. fehlender API-Key). + /// + public sealed record ModuleActivationInfo(string Name, bool IsRunning, string? Blocker); + /// Anzeige-Zeile für das Historie-Grid (Account bereits zu Name aufgelöst). public class DashboardTradeRow { diff --git a/src/PolyTrader.Core/Models/ServerSettings.cs b/src/PolyTrader.Core/Models/ServerSettings.cs index a4875a7..03351b0 100644 --- a/src/PolyTrader.Core/Models/ServerSettings.cs +++ b/src/PolyTrader.Core/Models/ServerSettings.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Xml.Serialization; @@ -6,6 +7,15 @@ namespace PolyTraderSharp.Models { public class ServerSettings { + [Category("Module")] + [DisplayName("Deaktivierte Module")] + [Description("Namen der Module (z.B. \"CopyTrading\"), die beim App-Start NICHT geladen werden. " + + "Leer = alle Module aktiv. Änderungen greifen nach dem nächsten Neustart. " + + "Wird i.d.R. über das Dashboard gepflegt.")] + [XmlArray("DisabledModules")] + [XmlArrayItem("Module")] + public List DisabledModules { get; set; } = new(); + [Category("Threema Notifications")] [DisplayName("Threema Enabled")] [Description("Enable or disable Threema notifications.")] diff --git a/src/PolyTrader.Core/Modularity/IPolyTraderModule.cs b/src/PolyTrader.Core/Modularity/IPolyTraderModule.cs index 7d07f79..87c920f 100644 --- a/src/PolyTrader.Core/Modularity/IPolyTraderModule.cs +++ b/src/PolyTrader.Core/Modularity/IPolyTraderModule.cs @@ -36,6 +36,13 @@ namespace PolyTrader.Core.Modularity /// void RegisterUi(IModuleUiHost host, IServiceProvider services); + /// + /// Optionale Vorab-Prüfung, ob das Modul aktiviert werden kann. Gibt null zurück, wenn + /// aktivierbar, sonst einen kurzen, im Dashboard anzeigbaren Grund (z.B. „Alchemy-API-Key fehlt"). + /// Default: immer aktivierbar. Module überschreiben dies bei harten Voraussetzungen. + /// + string? GetActivationBlocker(IConfiguration configuration) => null; + /// Wird nach der Core-Hydration gestartet (kein Anlaufen gegen leeren State). Task StartAsync(CancellationToken cancellationToken); diff --git a/src/PolyTrader.Core/Modularity/ModuleUi.cs b/src/PolyTrader.Core/Modularity/ModuleUi.cs index 146a747..8a45470 100644 --- a/src/PolyTrader.Core/Modularity/ModuleUi.cs +++ b/src/PolyTrader.Core/Modularity/ModuleUi.cs @@ -24,8 +24,12 @@ namespace PolyTrader.Core.Modularity /// Optionale Sortierreihenfolge. public int Order { get; init; } = 0; - /// Optionales Icon (16x16) für Menü/Buttons. Wird von der Shell/den Menüs genutzt. - public System.Drawing.Image? Icon { get; init; } + /// + /// Optionales Icon für Menü/Buttons. Wird von der Shell/den Menüs genutzt. Settable, damit die + /// App den von Modulen registrierten Views zentral ein Icon aus ihren Ressourcen zuweisen kann + /// (Module referenzieren die App-Ressourcen nicht). + /// + public System.Drawing.Image? Icon { get; set; } /// Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung). public Func
CreateForm { get; init; } = () => new Form(); @@ -53,6 +57,13 @@ namespace PolyTrader.Core.Modularity /// Holt das Hauptfenster (Launcher) in den Vordergrund. void ActivateMain(); + /// + /// Leitet das sichere Herunterfahren ein (Bestätigungsdialog mit Timer). Wird vom „Beenden"- + /// Eintrag JEDES Fensters aufgerufen – auch aus Modul-Fenstern, die die App nicht kennen. + /// Die konkrete Shell zeigt den Dialog und fährt bei Bestätigung geordnet herunter. + /// + void RequestShutdown(); + /// Feuert, wenn sich der Offen-Status irgendeiner View ändert. event Action? OpenStateChanged; } diff --git a/src/PolyTrader.Core/Modularity/WindowMenu.cs b/src/PolyTrader.Core/Modularity/WindowMenu.cs index 63e5376..d0a584d 100644 --- a/src/PolyTrader.Core/Modularity/WindowMenu.cs +++ b/src/PolyTrader.Core/Modularity/WindowMenu.cs @@ -6,48 +6,86 @@ using System.Windows.Forms; namespace PolyTrader.Core.Modularity { /// - /// Baut das gemeinsame „Fenster"-Menü, das auf JEDEM PolyTrader-Fenster erscheint und das Wechseln + /// Baut das gemeinsame Fenster-Menü, das auf JEDEM PolyTrader-Fenster erscheint und das Wechseln /// zwischen allen Fenstern (Launcher + Core + Module) erlaubt. Da es nur den Core-Contract /// nutzt, funktioniert es auch aus Modul-Fenstern (die die App nicht kennen). /// - /// Der Menü-Container liegt im Designer jedes Fensters (ein „Fenster"); - /// die dynamische Liste (Offen-Status ändert sich) wird hier beim Aufklappen frisch gefüllt: - /// offene Fenster sind angehakt, das aktuelle Fenster ist fett + angehakt markiert. + /// Die Fenster werden NICHT mehr in einem Untermenü „Fenster" versteckt, sondern als eigenständige + /// Einträge (mit Icon) direkt nebeneinander in der obersten Menüleiste aufgelistet. Das aktuelle + /// Fenster ist fett + angehakt, weitere offene Fenster sind angehakt. + /// Rechts liegt eine kontextabhängige Aktion: NUR auf dem Launcher (Hauptprozess) „Beenden" + /// (fährt die gesamte App über die Sicherheitsabfrage herunter); auf allen anderen Fenstern + /// „Fenster schließen" (schließt nur dieses Fenster – ohne Einfluss auf laufende Module). + /// Die Leiste wird bei jeder Statusänderung (Fenster geöffnet/geschlossen) neu aufgebaut. /// public static class WindowMenu { - /// Verdrahtet ein Designer-„Fenster"-Menüelement mit der Fensterliste (Neuaufbau beim Aufklappen). - public static void Wire(ToolStripMenuItem fensterMenu, IModuleUiHost host, string? currentViewId) + /// + /// Verdrahtet eine Designer- mit der Fensterliste: füllt sie sofort und + /// baut sie bei jeder Offen-Status-Änderung neu auf. Die Registrierung wird sauber gelöst, sobald + /// die Menüleiste (mit ihrem Fenster) entsorgt wird – kein Event-Leak über die Host-Lebensdauer. + /// + public static void Wire(MenuStrip menu, IModuleUiHost host, string? currentViewId) { - fensterMenu.DropDownOpening += (_, _) => Populate(fensterMenu, host, currentViewId); - Populate(fensterMenu, host, currentViewId); + void Refresh() + { + if (menu.IsDisposed) return; + if (menu.IsHandleCreated && menu.InvokeRequired) + { + try { menu.BeginInvoke((Action)(() => Populate(menu, host, currentViewId))); } + catch { /* Fenster wird gerade geschlossen */ } + return; + } + Populate(menu, host, currentViewId); + } + + Populate(menu, host, currentViewId); + host.OpenStateChanged += Refresh; + menu.Disposed += (_, _) => host.OpenStateChanged -= Refresh; } - public static void Populate(ToolStripMenuItem fensterMenu, IModuleUiHost host, string? currentViewId) + /// Baut die Menüleiste komplett neu auf (Launcher, alle Views nebeneinander, Beenden rechts). + public static void Populate(MenuStrip menu, IModuleUiHost host, string? currentViewId) { - fensterMenu.DropDownItems.Clear(); + menu.Items.Clear(); var launcher = new ToolStripMenuItem("Launcher") { Checked = currentViewId == null }; if (currentViewId == null) launcher.Font = new Font(launcher.Font, FontStyle.Bold); launcher.Click += (_, _) => host.ActivateMain(); - fensterMenu.DropDownItems.Add(launcher); - fensterMenu.DropDownItems.Add(new ToolStripSeparator()); + menu.Items.Add(launcher); foreach (var view in host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title)) { bool isCurrent = view.Id == currentViewId; - var item = new ToolStripMenuItem(view.Title) { Image = view.Icon }; - item.Checked = isCurrent || host.IsOpen(view.Id); + var item = new ToolStripMenuItem(view.Title) + { + Image = view.Icon, + ImageScaling = ToolStripItemImageScaling.SizeToFit, + DisplayStyle = view.Icon != null + ? ToolStripItemDisplayStyle.ImageAndText + : ToolStripItemDisplayStyle.Text, + Checked = isCurrent || host.IsOpen(view.Id) + }; if (isCurrent) item.Font = new Font(item.Font, FontStyle.Bold); string id = view.Id; item.Click += (_, _) => host.OpenView(id); - fensterMenu.DropDownItems.Add(item); + menu.Items.Add(item); } - fensterMenu.DropDownItems.Add(new ToolStripSeparator()); - var exit = new ToolStripMenuItem("Beenden"); - exit.Click += (_, _) => Application.Exit(); - fensterMenu.DropDownItems.Add(exit); + // Kontextabhängige rechte Aktion: nur der Launcher darf die App beenden (Sicherheitsabfrage); + // jedes andere Fenster bietet nur „Fenster schließen" (kein App-Shutdown, Module laufen weiter). + if (currentViewId == null) + { + var exit = new ToolStripMenuItem("Beenden") { Alignment = ToolStripItemAlignment.Right }; + exit.Click += (_, _) => host.RequestShutdown(); + menu.Items.Add(exit); + } + else + { + var close = new ToolStripMenuItem("Fenster schließen") { Alignment = ToolStripItemAlignment.Right }; + close.Click += (_, _) => menu.FindForm()?.Close(); + menu.Items.Add(close); + } } } } diff --git a/tests/PolyTrader.Tests/WindowMenuTests.cs b/tests/PolyTrader.Tests/WindowMenuTests.cs index 236f6b5..47f0397 100644 --- a/tests/PolyTrader.Tests/WindowMenuTests.cs +++ b/tests/PolyTrader.Tests/WindowMenuTests.cs @@ -17,10 +17,13 @@ namespace PolyTrader.Tests public List Opened { get; } = new(); public bool MainActivated { get; private set; } + public bool ShutdownRequested { get; private set; } + public IReadOnlyList Views => ViewList; public bool IsOpen(string viewId) => OpenIds.Contains(viewId); public void OpenView(string viewId) => Opened.Add(viewId); public void ActivateMain() => MainActivated = true; + public void RequestShutdown() => ShutdownRequested = true; public void RegisterView(ModuleView view) => ViewList.Add(view); public event Action? OpenStateChanged { add { } remove { } } } @@ -35,30 +38,64 @@ namespace PolyTrader.Tests } [Fact] - public void Populate_lists_launcher_views_and_exit() + public void Populate_lists_launcher_views_and_exit_side_by_side() { var host = Host(); - var fenster = new ToolStripMenuItem("Fenster"); + var menu = new MenuStrip(); - WindowMenu.Populate(fenster, host, currentViewId: "core.dashboard"); + // currentViewId == null => Launcher (Hauptprozess): rechte Aktion ist „Beenden". + WindowMenu.Populate(menu, host, currentViewId: null); - var texts = fenster.DropDownItems.OfType().Select(i => i.Text).ToList(); - Assert.Equal("Launcher", texts[0]); + var texts = menu.Items.OfType().Select(i => i.Text).ToList(); + Assert.Equal("Launcher", texts[0]); // Launcher als erster Top-Level-Eintrag Assert.Contains("Dashboard", texts); Assert.Contains("Terminal / Logs", texts); Assert.Contains("Beenden", texts); } + [Fact] + public void Non_launcher_window_offers_close_window_not_exit() + { + var host = Host(); + var menu = new MenuStrip(); + + // currentViewId != null => Modul-/Core-Fenster: KEIN „Beenden", nur „Fenster schließen". + WindowMenu.Populate(menu, host, currentViewId: "core.dashboard"); + + var texts = menu.Items.OfType().Select(i => i.Text).ToList(); + Assert.Contains("Fenster schließen", texts); + Assert.DoesNotContain("Beenden", texts); + + // Klick auf „Fenster schließen" fordert NICHT das App-Shutdown an. + menu.Items.OfType().First(i => i.Text == "Fenster schließen").PerformClick(); + Assert.False(host.ShutdownRequested); + } + + [Fact] + public void Views_carry_their_icon_in_the_menu() + { + var host = new FakeUiHost(); + using var icon = new System.Drawing.Bitmap(16, 16); + host.RegisterView(new ModuleView { Id = "core.dashboard", Title = "Dashboard", Order = 1, Icon = icon }); + var menu = new MenuStrip(); + + WindowMenu.Populate(menu, host, currentViewId: null); + + var dashboard = menu.Items.OfType().First(i => i.Text == "Dashboard"); + Assert.Same(icon, dashboard.Image); + Assert.Equal(ToolStripItemDisplayStyle.ImageAndText, dashboard.DisplayStyle); + } + [Fact] public void Current_view_is_bold_and_checked_open_view_is_checked() { var host = Host(); - var fenster = new ToolStripMenuItem("Fenster"); + var menu = new MenuStrip(); - WindowMenu.Populate(fenster, host, currentViewId: "core.dashboard"); + WindowMenu.Populate(menu, host, currentViewId: "core.dashboard"); - var dashboard = fenster.DropDownItems.OfType().First(i => i.Text == "Dashboard"); - var terminal = fenster.DropDownItems.OfType().First(i => i.Text == "Terminal / Logs"); + var dashboard = menu.Items.OfType().First(i => i.Text == "Dashboard"); + var terminal = menu.Items.OfType().First(i => i.Text == "Terminal / Logs"); Assert.True(dashboard.Checked); // aktuelles Fenster Assert.True(dashboard.Font.Bold); @@ -70,15 +107,27 @@ namespace PolyTrader.Tests public void Clicking_items_navigates() { var host = Host(); - var fenster = new ToolStripMenuItem("Fenster"); - WindowMenu.Populate(fenster, host, currentViewId: null); + var menu = new MenuStrip(); + WindowMenu.Populate(menu, host, currentViewId: null); - var items = fenster.DropDownItems.OfType().ToList(); + var items = menu.Items.OfType().ToList(); items.First(i => i.Text == "Launcher").PerformClick(); items.First(i => i.Text == "Terminal / Logs").PerformClick(); Assert.True(host.MainActivated); Assert.Contains("core.terminal", host.Opened); } + + [Fact] + public void Clicking_exit_requests_shutdown_not_immediate_exit() + { + var host = Host(); + var menu = new MenuStrip(); + WindowMenu.Populate(menu, host, currentViewId: null); + + menu.Items.OfType().First(i => i.Text == "Beenden").PerformClick(); + + Assert.True(host.ShutdownRequested); + } } }