diff --git a/IoListTestingWindow.EmbeddedEngineeringHost.cs b/IoListTestingWindow.EmbeddedEngineeringHost.cs new file mode 100644 index 000000000..5e434facc --- /dev/null +++ b/IoListTestingWindow.EmbeddedEngineeringHost.cs @@ -0,0 +1,240 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Hosts the proven IoListTestingWindow production workspace inside MainWindow's FAT tab. +/// The production Window remains loaded-but-hidden so its existing session controller, +/// auto-capture lifecycle, persistence and report-preview code stay the single FAT authority. +/// Only its central workspace + FAT status footer are re-parented into Engineering. +/// +public partial class IoListTestingWindow +{ + private bool _engineeringEmbeddedMountQueued; + private bool _engineeringEmbeddedMounted; + private FrameworkElement? _engineeringEmbeddedSurface; + + [ModuleInitializer] + internal static void RegisterEmbeddedEngineeringFatHost() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(EmbeddedEngineeringFatHost_Loaded), + handledEventsToo: true); + } + + private static void EmbeddedEngineeringFatHost_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || + !ReferenceEquals(e.OriginalSource, window) || + window._engineeringEmbeddedMounted || + window._engineeringEmbeddedMountQueued || + window.Owner is not MainWindow owner || + !owner.ProductionFatTabReady) + { + return; + } + + window._engineeringEmbeddedMountQueued = true; + + // MainWindow's legacy launcher still calls Show() on this Window. Make that bootstrap + // surface invisible immediately; the actual production visual is moved into Engineering + // on the next Loaded-priority dispatcher turn, before ContextIdle command-panel work. + window.ShowActivated = false; + window.ShowInTaskbar = false; + window.Opacity = 0d; + + window.Dispatcher.BeginInvoke( + DispatcherPriority.Loaded, + new Action(() => window.TryMountIntoEngineering(owner))); + } + + private void TryMountIntoEngineering(MainWindow owner) + { + _engineeringEmbeddedMountQueued = false; + if (_engineeringEmbeddedMounted || !IsLoaded || !ReferenceEquals(Owner, owner) || !owner.ProductionFatTabReady) + return; + + try + { + EnsureProductionFatPresentationForEmbeddedHost(); + DisableLegacyEmbeddedCommandPanel(); + var surface = DetachProductionFatCentralWorkspace(); + if (surface == null) + return; + + _engineeringEmbeddedSurface = surface; + _engineeringEmbeddedMounted = owner.MountProductionFatWorkspace(this, surface); + if (!_engineeringEmbeddedMounted) + return; + + // The central FAT view now belongs to MainWindow. Keep this Window loaded and + // hidden because existing controller/session/event code is intentionally reused. + Hide(); + } + catch (Exception ex) + { + Opacity = 1d; + ShowInTaskbar = true; + ShowActivated = true; + MessageBox.Show( + owner, + $"ARSAS could not embed the production FAT workspace. The standalone FAT window will remain available.\n\n{ex.Message}", + "FAT workspace host", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + + private void EnsureProductionFatPresentationForEmbeddedHost() + { + // P1 normally installs this during Loaded. Calling it explicitly is safe/idempotent + // and guarantees the first embedded frame is the same V2 FAT grid as the old Window. + InstallFatV2WorkspaceUx(); + + // PrintPreview historically installs from OnContentRendered. The bootstrap Window is + // intentionally transparent, so install the exact same production preview explicitly + // before the central workspace is detached. This preserves full-center mode switching. + if (!_printPreviewInstalled) + { + InstallPerIedPrintPreview(); + PropertyChanged += PrintPreviewWindow_PropertyChanged; + Session.PropertyChanged += PrintPreviewSession_PropertyChanged; + Closed += PrintPreviewWindow_Closed; + _printPreviewInstalled = true; + } + + // The embedded center already declares WorkspacePreviewToggle in XAML. Make that + // visible button the production toggle authority instead of the hidden Window header. + if (WorkspacePreviewToggle != null) + _printPreviewToggle = WorkspacePreviewToggle; + } + + private void DisableLegacyEmbeddedCommandPanel() + { + // Engineering already owns one shared Command Dock. Prevent the old FAT window's + // duplicate command panel from being created/refreshed while embedded. A non-null + // sentinel makes the queued legacy installer return immediately; null row/summary + // references make its queued refresh a no-op. No command runtime semantics change. + DetachFatCommandDevice(); + if (_fatCommandPanelShell?.Parent is Grid existingHost) + { + var row = Grid.GetRow(_fatCommandPanelShell); + existingHost.Children.Remove(_fatCommandPanelShell); + if (row >= 0 && row < existingHost.RowDefinitions.Count) + existingHost.RowDefinitions[row].Height = new GridLength(0); + if (row - 1 >= 0 && row - 1 < existingHost.RowDefinitions.Count) + existingHost.RowDefinitions[row - 1].Height = new GridLength(0); + } + + _fatCommandPanelShell ??= new Border { Visibility = Visibility.Collapsed }; + _fatCommandRows = null; + _fatCommandSummary = null; + _fatCommandEmptyState = null; + } + + private FrameworkElement? DetachProductionFatCentralWorkspace() + { + if (Content is not Grid root) + return null; + + var middle = root.Children + .OfType() + .FirstOrDefault(child => Grid.GetRow(child) == 2); + var workspaceBorder = middle?.Children + .OfType() + .FirstOrDefault(child => Grid.GetColumn(child) == 2); + if (middle == null || workspaceBorder == null) + return null; + + var footer = root.Children + .OfType() + .FirstOrDefault(child => Grid.GetRow(child) == 4); + + middle.Children.Remove(workspaceBorder); + if (footer != null) + root.Children.Remove(footer); + + var host = new Grid + { + DataContext = this, + Margin = new Thickness(0) + }; + host.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + if (footer != null) + { + host.RowDefinitions.Add(new RowDefinition { Height = new GridLength(8) }); + host.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + } + + workspaceBorder.Margin = new Thickness(0); + Grid.SetRow(workspaceBorder, 0); + Grid.SetColumn(workspaceBorder, 0); + host.Children.Add(workspaceBorder); + + if (footer != null) + { + footer.Margin = new Thickness(0); + Grid.SetRow(footer, 2); + Grid.SetColumn(footer, 0); + host.Children.Add(footer); + } + + return host; + } + + internal void SelectEngineeringDeviceForEmbeddedFat(Iec61850MonitorDevice? device) + { + if (!_engineeringEmbeddedMounted || device == null) + return; + + var match = Project.Ieds.FirstOrDefault(ied => + (!string.IsNullOrWhiteSpace(ied.LiveDeviceId) && + ied.LiveDeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(ied.IpAddress) && + ied.IpAddress.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase)) || + ied.IedName.Equals(device.SclIedName, StringComparison.OrdinalIgnoreCase) || + ied.IedName.Equals(device.Name, StringComparison.OrdinalIgnoreCase)); + if (match == null || ReferenceEquals(SelectedIed, match)) + return; + + // Do not retarget an active production FAT transaction/session. When idle, the + // persistent Engineering IED Explorer is the navigation/selection authority. + if (!CanSelectIed) + return; + + SelectedIed = match; + } + + internal void NotifyEmbeddedHostActivated() + { + if (!_engineeringEmbeddedMounted) + return; + + RefreshFatV2WorkspaceUx(refreshRows: true); + if (_printPreviewActive) + RefreshPrintPreview(); + } + + private void EmbeddedEngineeringFatHost_Closed(object? sender, EventArgs e) + { + if (Owner is MainWindow owner) + owner.UnmountProductionFatWorkspace(this); + Closed -= EmbeddedEngineeringFatHost_Closed; + _engineeringEmbeddedMounted = false; + _engineeringEmbeddedSurface = null; + } + + // Field initializer cannot attach an instance event. Hook cleanup once the embedded + // surface has actually been mounted; this helper is called from the production owner. + internal void RegisterEmbeddedHostCloseCleanup() + { + Closed -= EmbeddedEngineeringFatHost_Closed; + Closed += EmbeddedEngineeringFatHost_Closed; + } +} diff --git a/MainWindow.NativeFatExplorerSync.cs b/MainWindow.NativeFatExplorerSync.cs new file mode 100644 index 000000000..8ea74646d --- /dev/null +++ b/MainWindow.NativeFatExplorerSync.cs @@ -0,0 +1,213 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Keeps the native FAT projection aligned with the persistent IED Explorer's signal +/// selection without turning FAT into a second signal database. +/// +/// ObservableCollection changes were already reconciled by the first P2 slice. Explorer +/// checkbox changes are different: SignalDefinition stays in the collection and only +/// IsSelected changes. This bridge observes that property, marks the FAT scope dirty, and +/// forces non-destructive reconciliation when FAT is visible (or the next time it opens). +/// +public partial class MainWindow +{ + private DispatcherTimer? _nativeFatExplorerSyncInstallRetry; + private DispatcherTimer? _nativeFatExplorerScopeTimer; + private Iec61850MonitorDevice? _nativeFatExplorerSyncDevice; + private readonly HashSet _nativeFatExplorerObservedSignals = new(); + private bool _nativeFatExplorerSyncAttached; + private bool _nativeFatExplorerScopeDirty; + + [ModuleInitializer] + internal static void RegisterNativeFatExplorerSelectionSync() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatExplorerSync_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatExplorerSync_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatExplorerSyncAttached) + return; + + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(window.TryAttachNativeFatExplorerSelectionSync)); + } + + private void TryAttachNativeFatExplorerSelectionSync() + { + if (_nativeFatExplorerSyncAttached || !IsLoaded) + return; + + if (!_nativeFatInstalled) + { + _nativeFatExplorerSyncInstallRetry ??= new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(180) + }; + _nativeFatExplorerSyncInstallRetry.Tick -= NativeFatExplorerSyncInstallRetry_Tick; + _nativeFatExplorerSyncInstallRetry.Tick += NativeFatExplorerSyncInstallRetry_Tick; + _nativeFatExplorerSyncInstallRetry.Start(); + return; + } + + _nativeFatExplorerSyncInstallRetry?.Stop(); + _nativeFatExplorerSyncAttached = true; + + _nativeFatExplorerScopeTimer = new DispatcherTimer(DispatcherPriority.Background) + { + Interval = TimeSpan.FromMilliseconds(180) + }; + _nativeFatExplorerScopeTimer.Tick += NativeFatExplorerScopeTimer_Tick; + + PropertyChanged += NativeFatExplorerSync_MainWindowPropertyChanged; + MainTabs.SelectionChanged += NativeFatExplorerSync_MainTabsSelectionChanged; + Closed += NativeFatExplorerSync_MainWindowClosed; + RebindNativeFatExplorerSignals(SelectedDevice); + } + + private void NativeFatExplorerSyncInstallRetry_Tick(object? sender, EventArgs e) + { + _nativeFatExplorerSyncInstallRetry?.Stop(); + TryAttachNativeFatExplorerSelectionSync(); + } + + private void NativeFatExplorerSync_MainWindowPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedDevice)) + return; + + RebindNativeFatExplorerSignals(SelectedDevice); + MarkNativeFatExplorerScopeDirty(); + } + + private void RebindNativeFatExplorerSignals(Iec61850MonitorDevice? device) + { + if (ReferenceEquals(_nativeFatExplorerSyncDevice, device)) + { + SyncNativeFatExplorerSignalSubscriptions(); + return; + } + + if (_nativeFatExplorerSyncDevice != null) + _nativeFatExplorerSyncDevice.Signals.CollectionChanged -= NativeFatExplorerSignals_CollectionChanged; + + foreach (var signal in _nativeFatExplorerObservedSignals) + signal.PropertyChanged -= NativeFatExplorerSignal_PropertyChanged; + _nativeFatExplorerObservedSignals.Clear(); + + _nativeFatExplorerSyncDevice = device; + if (_nativeFatExplorerSyncDevice != null) + { + _nativeFatExplorerSyncDevice.Signals.CollectionChanged += NativeFatExplorerSignals_CollectionChanged; + SyncNativeFatExplorerSignalSubscriptions(); + } + } + + private void NativeFatExplorerSignals_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + SyncNativeFatExplorerSignalSubscriptions(); + MarkNativeFatExplorerScopeDirty(); + } + + private void SyncNativeFatExplorerSignalSubscriptions() + { + var current = _nativeFatExplorerSyncDevice?.Signals.ToHashSet() ?? new HashSet(); + + foreach (var signal in _nativeFatExplorerObservedSignals.Where(signal => !current.Contains(signal)).ToArray()) + { + signal.PropertyChanged -= NativeFatExplorerSignal_PropertyChanged; + _nativeFatExplorerObservedSignals.Remove(signal); + } + + foreach (var signal in current) + { + if (!_nativeFatExplorerObservedSignals.Add(signal)) + continue; + signal.PropertyChanged += NativeFatExplorerSignal_PropertyChanged; + } + } + + private void NativeFatExplorerSignal_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // IsSelected is the Explorer membership authority. Value/quality/timestamp changes + // stay live-bound directly and must never trigger a full FAT reconciliation. + if (e.PropertyName == nameof(SignalDefinition.IsSelected)) + MarkNativeFatExplorerScopeDirty(); + } + + private void MarkNativeFatExplorerScopeDirty() + { + _nativeFatExplorerScopeDirty = true; + if (MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + _nativeFatExplorerScopeTimer?.Stop(); + _nativeFatExplorerScopeTimer?.Start(); + } + + private async void NativeFatExplorerScopeTimer_Tick(object? sender, EventArgs e) + { + _nativeFatExplorerScopeTimer?.Stop(); + if (!_nativeFatExplorerScopeDirty || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + await EnsureNativeFatLoadedAsync(forceReconcile: true); + _nativeFatExplorerScopeDirty = false; + } + + private void NativeFatExplorerSync_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.Source, MainTabs) || + MainTabs.SelectedIndex != NativeFatWorkspaceIndex || + !_nativeFatExplorerScopeDirty) + { + return; + } + + // The base P2 selection handler may have issued a fast non-forced load already. + // Re-run at ContextIdle with forceReconcile so checkbox changes made while another + // workspace was active are never hidden behind the current-state fast path. + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(async () => + { + if (MainTabs.SelectedIndex != NativeFatWorkspaceIndex || !_nativeFatExplorerScopeDirty) + return; + await EnsureNativeFatLoadedAsync(forceReconcile: true); + _nativeFatExplorerScopeDirty = false; + })); + } + + private void NativeFatExplorerSync_MainWindowClosed(object? sender, EventArgs e) + { + _nativeFatExplorerSyncInstallRetry?.Stop(); + _nativeFatExplorerScopeTimer?.Stop(); + + PropertyChanged -= NativeFatExplorerSync_MainWindowPropertyChanged; + MainTabs.SelectionChanged -= NativeFatExplorerSync_MainTabsSelectionChanged; + Closed -= NativeFatExplorerSync_MainWindowClosed; + + if (_nativeFatExplorerSyncDevice != null) + _nativeFatExplorerSyncDevice.Signals.CollectionChanged -= NativeFatExplorerSignals_CollectionChanged; + foreach (var signal in _nativeFatExplorerObservedSignals) + signal.PropertyChanged -= NativeFatExplorerSignal_PropertyChanged; + + _nativeFatExplorerObservedSignals.Clear(); + _nativeFatExplorerSyncDevice = null; + _nativeFatExplorerSyncAttached = false; + _nativeFatExplorerScopeDirty = false; + } +} diff --git a/MainWindow.NativeFatExport.cs b/MainWindow.NativeFatExport.cs new file mode 100644 index 000000000..426cd18c8 --- /dev/null +++ b/MainWindow.NativeFatExport.cs @@ -0,0 +1,166 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Services; +using Microsoft.Win32; + +namespace ArIED61850Tester; + +/// +/// Native evidence export entry point. Export consumes the immutable report snapshot +/// created from the already-loaded FAT state; it never creates a second IED session. +/// +public partial class MainWindow +{ + private DispatcherTimer? _nativeFatExportInstallRetry; + private Button? _nativeFatExportPdfButton; + private bool _nativeFatExportInstalled; + + [ModuleInitializer] + internal static void RegisterNativeFatExport() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatExport_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatExport_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatExportInstalled) + return; + + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(window.TryInstallNativeFatExport)); + } + + private void TryInstallNativeFatExport() + { + if (_nativeFatExportInstalled || !IsLoaded) + return; + + if (!_nativeFatInstalled || _nativeFatSearchBox?.Parent is not WrapPanel toolbar) + { + _nativeFatExportInstallRetry ??= new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(190) + }; + _nativeFatExportInstallRetry.Tick -= NativeFatExportInstallRetry_Tick; + _nativeFatExportInstallRetry.Tick += NativeFatExportInstallRetry_Tick; + _nativeFatExportInstallRetry.Start(); + return; + } + + _nativeFatExportInstallRetry?.Stop(); + _nativeFatExportInstalled = true; + _nativeFatExportPdfButton = CreateNativeFatButton( + "Export PDF", + NativeFatExportPdf_Click, + "Export an immutable native FAT evidence PDF for the selected IED. Show historical controls whether historical signal rows are included."); + + var historyIndex = _nativeFatShowHistoricalCheck == null + ? toolbar.Children.Count + : toolbar.Children.IndexOf(_nativeFatShowHistoricalCheck); + if (historyIndex < 0) + historyIndex = toolbar.Children.Count; + toolbar.Children.Insert(historyIndex, _nativeFatExportPdfButton); + + Closed += NativeFatExport_MainWindowClosed; + } + + private void NativeFatExportInstallRetry_Tick(object? sender, EventArgs e) + { + _nativeFatExportInstallRetry?.Stop(); + TryInstallNativeFatExport(); + } + + private async void NativeFatExportPdf_Click(object sender, RoutedEventArgs e) + { + if (_nativeFatExportPdfButton == null) + return; + + await EnsureNativeFatLoadedAsync(forceReconcile: false); + var device = SelectedDevice; + var state = _nativeFatCurrentState; + if (device == null || state == null || + !state.DeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + SetNativeFatStatus("Select an IED with a loaded FAT state before exporting."); + return; + } + + var includeHistorical = _nativeFatShowHistoricalCheck?.IsChecked == true; + var snapshot = NativeFatReportSnapshotBuilder.Build(device, state); + var dialog = new SaveFileDialog + { + Title = "Export ARSAS native FAT evidence PDF", + Filter = "PDF evidence report (*.pdf)|*.pdf", + FileName = $"{SafeNativeFatFileName(device.Name)}_FAT_{DateTime.Now:yyyyMMdd_HHmm}.pdf", + AddExtension = true, + DefaultExt = ".pdf", + OverwritePrompt = true + }; + if (dialog.ShowDialog(this) != true) + return; + + var previousContent = _nativeFatExportPdfButton.Content; + try + { + _nativeFatExportPdfButton.IsEnabled = false; + _nativeFatExportPdfButton.Content = "Exporting…"; + SetNativeFatStatus("Saving current FAT state and building evidence PDF…"); + + // Persist the exact state used to construct the snapshot before delivering + // an external evidence file. The snapshot itself remains frozen thereafter. + await SaveNativeFatStateAsync(state); + await Task.Run(() => NativeFatPdfReportService.Save( + dialog.FileName, + snapshot, + includeHistorical)); + + SetNativeFatStatus($"PDF exported · {Path.GetFileName(dialog.FileName)}"); + AddLog( + "INFO", + "Native FAT", + $"Native FAT PDF exported for {device.Name}: {dialog.FileName}" + + (includeHistorical ? " (historical included)" : string.Empty)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or ArgumentException) + { + AddLog("WARN", "Native FAT", $"PDF export failed: {ex.Message}"); + SetNativeFatStatus("PDF export failed. Native FAT state remains saved."); + MessageBox.Show( + this, + ex.Message, + "Native FAT PDF export failed", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + _nativeFatExportPdfButton.Content = previousContent ?? "Export PDF"; + _nativeFatExportPdfButton.IsEnabled = true; + } + } + + private static string SafeNativeFatFileName(string? value) + { + var source = string.IsNullOrWhiteSpace(value) ? "IED" : value.Trim(); + var invalid = Path.GetInvalidFileNameChars().ToHashSet(); + var result = new string(source.Select(character => invalid.Contains(character) ? '_' : character).ToArray()) + .Trim() + .Trim('.'); + return string.IsNullOrWhiteSpace(result) ? "IED" : result; + } + + private void NativeFatExport_MainWindowClosed(object? sender, EventArgs e) + { + _nativeFatExportInstallRetry?.Stop(); + if (_nativeFatExportPdfButton != null) + _nativeFatExportPdfButton.Click -= NativeFatExportPdf_Click; + Closed -= NativeFatExport_MainWindowClosed; + } +} diff --git a/MainWindow.NativeFatHistoryInspector.cs b/MainWindow.NativeFatHistoryInspector.cs new file mode 100644 index 000000000..27b24d82c --- /dev/null +++ b/MainWindow.NativeFatHistoryInspector.cs @@ -0,0 +1,178 @@ +using System.Runtime.CompilerServices; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Makes the native FAT report preview useful as an operator evidence/history inspector. +/// The immutable report snapshot remains the preview/export authority; this enhancement +/// only appends the persisted chronological audit trail for the selected IEC identity. +/// +public partial class MainWindow +{ + private DispatcherTimer? _nativeFatHistoryInspectorInstallRetry; + private ScrollViewer? _nativeFatHistoryScrollViewer; + private bool _nativeFatHistoryInspectorAttached; + private bool _nativeFatHistoryRenderQueued; + + [ModuleInitializer] + internal static void RegisterNativeFatHistoryInspector() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatHistoryInspector_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatHistoryInspector_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatHistoryInspectorAttached) + return; + + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(window.TryAttachNativeFatHistoryInspector)); + } + + private void TryAttachNativeFatHistoryInspector() + { + if (_nativeFatHistoryInspectorAttached || !IsLoaded) + return; + + if (!_nativeFatReportPreviewEnhanced || + _nativeFatPreviewPane?.Child is not Grid previewRoot || + _nativeFatPreviewGrid == null || + _nativeFatPreviewEvidenceText == null) + { + _nativeFatHistoryInspectorInstallRetry ??= new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(180) + }; + _nativeFatHistoryInspectorInstallRetry.Tick -= NativeFatHistoryInspectorInstallRetry_Tick; + _nativeFatHistoryInspectorInstallRetry.Tick += NativeFatHistoryInspectorInstallRetry_Tick; + _nativeFatHistoryInspectorInstallRetry.Start(); + return; + } + + _nativeFatHistoryInspectorInstallRetry?.Stop(); + _nativeFatHistoryInspectorAttached = true; + + // The evidence block can become long once retest history exists. Keep the report + // pane compact and give only the evidence/history area its own vertical scrolling. + if (previewRoot.Children.Contains(_nativeFatPreviewEvidenceText)) + previewRoot.Children.Remove(_nativeFatPreviewEvidenceText); + + _nativeFatPreviewEvidenceText.Margin = new Thickness(0); + _nativeFatPreviewEvidenceText.TextWrapping = TextWrapping.Wrap; + _nativeFatHistoryScrollViewer = new ScrollViewer + { + Content = _nativeFatPreviewEvidenceText, + Margin = new Thickness(0, 8, 0, 0), + MaxHeight = 190, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + CanContentScroll = false + }; + Grid.SetRow(_nativeFatHistoryScrollViewer, 5); + previewRoot.Children.Add(_nativeFatHistoryScrollViewer); + + _nativeFatPreviewGrid.SelectionChanged += NativeFatHistoryInspector_SelectionChanged; + Closed += NativeFatHistoryInspector_MainWindowClosed; + QueueNativeFatHistoryInspectorRender(); + } + + private void NativeFatHistoryInspectorInstallRetry_Tick(object? sender, EventArgs e) + { + _nativeFatHistoryInspectorInstallRetry?.Stop(); + TryAttachNativeFatHistoryInspector(); + } + + private void NativeFatHistoryInspector_SelectionChanged(object sender, SelectionChangedEventArgs e) + => QueueNativeFatHistoryInspectorRender(); + + private void QueueNativeFatHistoryInspectorRender() + { + if (!_nativeFatHistoryInspectorAttached || _nativeFatHistoryRenderQueued) + return; + + _nativeFatHistoryRenderQueued = true; + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(() => + { + _nativeFatHistoryRenderQueued = false; + RenderNativeFatHistoryInspector(); + })); + } + + private void RenderNativeFatHistoryInspector() + { + if (_nativeFatPreviewEvidenceText == null) + return; + + if (_nativeFatPreviewGrid?.SelectedItem is not NativeFatReportRow reportRow) + { + _nativeFatPreviewEvidenceText.Text = "Select a report row to inspect captured evidence and retest history."; + return; + } + + var text = new StringBuilder(reportRow.EvidenceSummaryText); + var state = _nativeFatCurrentState; + var key = NativeFatIdentity.BuildKey(reportRow.IecReference, reportRow.FunctionalConstraint); + var persisted = state?.Signals.FirstOrDefault(signal => + signal.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); + var history = persisted?.History ?? new List(); + + text.Append("\n\nHISTORY · latest first"); + if (history.Count == 0) + { + text.Append("\nNo previous capture/result transitions recorded."); + } + else + { + foreach (var entry in history + .OrderByDescending(item => item.TimestampUtc) + .Take(8)) + { + text.Append("\n") + .Append(entry.TimestampUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")) + .Append(" · ") + .Append(string.IsNullOrWhiteSpace(entry.Action) ? "State update" : entry.Action.Trim()); + + if (!string.IsNullOrWhiteSpace(entry.Result)) + text.Append(" · ").Append(entry.Result.Trim()); + if (entry.Value1 != null) + text.Append(" · V1=").Append(entry.Value1.Value); + if (entry.Value2 != null) + text.Append(" · V2=").Append(entry.Value2.Value); + if (!string.IsNullOrWhiteSpace(entry.Note)) + text.Append(" · ").Append(entry.Note.Trim()); + } + + if (history.Count > 8) + text.Append("\n+").Append(history.Count - 8).Append(" earlier record(s) retained in the per-IED JSON."); + } + + if (reportRow.IsHistorical) + text.Append("\n\nThis IEC identity is historical: it is no longer in the current Explorer scope, but its FAT evidence is retained."); + + _nativeFatPreviewEvidenceText.Text = text.ToString(); + _nativeFatHistoryScrollViewer?.ScrollToTop(); + } + + private void NativeFatHistoryInspector_MainWindowClosed(object? sender, EventArgs e) + { + _nativeFatHistoryInspectorInstallRetry?.Stop(); + if (_nativeFatPreviewGrid != null) + _nativeFatPreviewGrid.SelectionChanged -= NativeFatHistoryInspector_SelectionChanged; + Closed -= NativeFatHistoryInspector_MainWindowClosed; + _nativeFatHistoryInspectorAttached = false; + _nativeFatHistoryRenderQueued = false; + _nativeFatHistoryScrollViewer = null; + } +} diff --git a/MainWindow.NativeFatPersistenceSafety.cs b/MainWindow.NativeFatPersistenceSafety.cs new file mode 100644 index 000000000..4f7e192b2 --- /dev/null +++ b/MainWindow.NativeFatPersistenceSafety.cs @@ -0,0 +1,120 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// Persistence durability guard for native FAT. +/// +/// The normal 350 ms autosave debounce keeps multi-row operations cheap, but two short +/// windows still need explicit protection: +/// 1) the operator switches IED before the debounce fires; and +/// 2) the operator closes ARSAS immediately after a capture/result change. +/// +/// Native FAT caches one state object per stable IED. Flush inactive cached states after +/// a SelectedDevice change and flush every cached state synchronously during Closing so +/// evidence from the previously selected relay cannot be stranded in memory. +/// +public partial class MainWindow +{ + private bool _nativeFatPersistenceSafetyAttached; + private bool _nativeFatSwitchFlushQueued; + + [ModuleInitializer] + internal static void RegisterNativeFatPersistenceSafety() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatPersistenceSafety_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatPersistenceSafety_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatPersistenceSafetyAttached) + return; + + window._nativeFatPersistenceSafetyAttached = true; + window.PropertyChanged += window.NativeFatPersistenceSafety_PropertyChanged; + window.Closing += window.NativeFatPersistenceSafety_Closing; + window.Closed += window.NativeFatPersistenceSafety_Closed; + } + + private void NativeFatPersistenceSafety_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedDevice) || !_nativeFatInstalled || _nativeFatSwitchFlushQueued) + return; + + // Let the normal SelectedDevice handler finish rebinding the workspace first. + // The previous state remains in _nativeFatStateCache, so a ContextIdle flush can + // save it without blocking the IED switch or relying on _nativeFatCurrentState. + _nativeFatSwitchFlushQueued = true; + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(() => + { + _nativeFatSwitchFlushQueued = false; + _ = NativeFatPersistenceSafety_FlushInactiveStatesAsync(); + })); + } + + private async Task NativeFatPersistenceSafety_FlushInactiveStatesAsync() + { + if (_nativeFatStateCache.Count == 0) + return; + + var current = _nativeFatCurrentState; + var inactive = _nativeFatStateCache.Values + .Where(state => !ReferenceEquals(state, current)) + .Distinct() + .ToArray(); + + foreach (var state in inactive) + await SaveNativeFatStateAsync(state); + } + + private void NativeFatPersistenceSafety_Closing(object? sender, CancelEventArgs e) + { + _nativeFatSaveTimer?.Stop(); + + var states = _nativeFatStateCache.Values.Distinct().ToList(); + if (_nativeFatCurrentState != null && !states.Contains(_nativeFatCurrentState)) + states.Add(_nativeFatCurrentState); + if (states.Count == 0) + return; + + foreach (var state in states) + { + try + { + // Intentionally bypass the UI save gate here. A debounced asynchronous + // save may currently own that gate and need the dispatcher for its + // continuation; waiting for the gate synchronously could deadlock Closing. + // NativeFatStateStore uses unique temp files + atomic replace, so a + // concurrent same-state flush is safe and never truncates the valid file. + _nativeFatStore.SaveAsync(state, CancellationToken.None).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + // Shutdown must remain possible. Any previous valid JSON is preserved; + // record the specific IED failure while diagnostics are still available. + AddLog( + "WARN", + "Native FAT", + $"Final FAT persistence flush failed for {state.IedName}: {ex.Message}"); + } + } + } + + private void NativeFatPersistenceSafety_Closed(object? sender, EventArgs e) + { + PropertyChanged -= NativeFatPersistenceSafety_PropertyChanged; + Closing -= NativeFatPersistenceSafety_Closing; + Closed -= NativeFatPersistenceSafety_Closed; + _nativeFatPersistenceSafetyAttached = false; + _nativeFatSwitchFlushQueued = false; + } +} diff --git a/MainWindow.NativeFatReportPreview.cs b/MainWindow.NativeFatReportPreview.cs new file mode 100644 index 000000000..bda0a5a72 --- /dev/null +++ b/MainWindow.NativeFatReportPreview.cs @@ -0,0 +1,279 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// P2 report-preview hardening for the native FAT workspace. The preview consumes an +/// immutable evidence snapshot instead of the live row objects, while the main FAT grid +/// remains directly bound to Explorer/runtime values for testing. +/// +public partial class MainWindow +{ + private DispatcherTimer? _nativeFatReportPreviewInstallRetry; + private TextBlock? _nativeFatPreviewEvidenceText; + private NativeFatReportSnapshot? _nativeFatReportSnapshot; + private readonly HashSet _nativeFatReportObservedRows = new(); + private bool _nativeFatReportPreviewEnhanced; + private bool _nativeFatReportRefreshQueued; + + [ModuleInitializer] + internal static void RegisterNativeFatReportPreviewEnhancement() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatReportPreview_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatReportPreview_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatReportPreviewEnhanced) + return; + + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(window.TryInstallNativeFatReportPreviewEnhancement)); + } + + private void TryInstallNativeFatReportPreviewEnhancement() + { + if (_nativeFatReportPreviewEnhanced || !IsLoaded) + return; + + if (!_nativeFatInstalled || _nativeFatPreviewPane?.Child is not Grid previewRoot || + _nativeFatPreviewGrid == null || _nativeFatPreviewSummaryText == null) + { + _nativeFatReportPreviewInstallRetry ??= new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(180) + }; + _nativeFatReportPreviewInstallRetry.Tick -= NativeFatReportPreviewInstallRetry_Tick; + _nativeFatReportPreviewInstallRetry.Tick += NativeFatReportPreviewInstallRetry_Tick; + _nativeFatReportPreviewInstallRetry.Start(); + return; + } + + _nativeFatReportPreviewInstallRetry?.Stop(); + _nativeFatReportPreviewEnhanced = true; + + // The initial P2 pane already has title/device/summary/grid/persistence rows. Add + // one compact evidence inspector between the grid and persistence note. + previewRoot.RowDefinitions.Insert(5, new RowDefinition { Height = GridLength.Auto }); + if (_nativeFatPersistenceText != null) + Grid.SetRow(_nativeFatPersistenceText, 6); + + _nativeFatPreviewEvidenceText = new TextBlock + { + Text = "Select a report row to inspect captured evidence.", + Margin = new Thickness(0, 8, 0, 0), + Padding = new Thickness(8, 7, 8, 7), + FontSize = 9.4, + Foreground = ResourceBrush("Muted", Color.FromRgb(0x66, 0x75, 0x8B)), + Background = new SolidColorBrush(Color.FromRgb(0xF7, 0xF9, 0xFC)), + TextWrapping = TextWrapping.Wrap + }; + Grid.SetRow(_nativeFatPreviewEvidenceText, 5); + previewRoot.Children.Add(_nativeFatPreviewEvidenceText); + + // Existing preview columns deliberately use property names also exposed by the + // snapshot row. Add the canonical IEC identity so report scope can be audited. + if (_nativeFatPreviewGrid.Columns.All(column => !Equals(column.Header, "IEC"))) + { + _nativeFatPreviewGrid.Columns.Insert(1, new DataGridTextColumn + { + Header = "IEC", + Binding = new Binding(nameof(NativeFatReportRow.IecReference)) { Mode = BindingMode.OneWay }, + Width = new DataGridLength(1.15, DataGridLengthUnitType.Star), + MinWidth = 105 + }); + } + + if (_nativeFatPreviewGrid.Columns.Count >= 5) + { + _nativeFatPreviewGrid.Columns[0].Width = new DataGridLength(1.15, DataGridLengthUnitType.Star); + _nativeFatPreviewGrid.Columns[2].Width = new DataGridLength(0.58, DataGridLengthUnitType.Star); + _nativeFatPreviewGrid.Columns[3].Width = new DataGridLength(0.58, DataGridLengthUnitType.Star); + _nativeFatPreviewGrid.Columns[4].Width = new DataGridLength(0.62, DataGridLengthUnitType.Star); + } + + _nativeFatPreviewGrid.SelectionChanged += NativeFatPreviewGrid_SelectionChanged; + _nativeFatPreviewPane!.IsVisibleChanged += NativeFatPreviewPane_IsVisibleChanged; + _nativeFatRows.CollectionChanged += NativeFatReportRows_CollectionChanged; + if (_nativeFatShowHistoricalCheck != null) + { + _nativeFatShowHistoricalCheck.Checked += NativeFatReportScope_Changed; + _nativeFatShowHistoricalCheck.Unchecked += NativeFatReportScope_Changed; + } + + SyncNativeFatReportRowSubscriptions(); + Closed += NativeFatReportPreview_MainWindowClosed; + QueueNativeFatReportPreviewRefresh(); + } + + private void NativeFatReportPreviewInstallRetry_Tick(object? sender, EventArgs e) + { + _nativeFatReportPreviewInstallRetry?.Stop(); + TryInstallNativeFatReportPreviewEnhancement(); + } + + private void NativeFatPreviewPane_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) + { + if (_nativeFatPreviewPane?.IsVisible == true && _nativeFatPreviewColumn != null) + { + // 330 px was enough for a four-column mock preview but not for auditable IEC + // identity + evidence. Keep it compact while making the report pane useful. + _nativeFatPreviewColumn.Width = new GridLength(430); + QueueNativeFatReportPreviewRefresh(); + } + } + + private void NativeFatReportRows_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + SyncNativeFatReportRowSubscriptions(); + QueueNativeFatReportPreviewRefresh(); + } + + private void SyncNativeFatReportRowSubscriptions() + { + var current = _nativeFatRows.ToHashSet(); + foreach (var row in _nativeFatReportObservedRows.Where(row => !current.Contains(row)).ToArray()) + { + row.PropertyChanged -= NativeFatReportRow_PropertyChanged; + _nativeFatReportObservedRows.Remove(row); + } + + foreach (var row in current) + { + if (!_nativeFatReportObservedRows.Add(row)) + continue; + row.PropertyChanged += NativeFatReportRow_PropertyChanged; + } + } + + private void NativeFatReportRow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(NativeFatSignalRow.Value1Text) or + nameof(NativeFatSignalRow.Value2Text) or + nameof(NativeFatSignalRow.Result) or + nameof(NativeFatSignalRow.HistoryText) or + nameof(NativeFatSignalRow.IsHistorical)) + { + QueueNativeFatReportPreviewRefresh(); + } + } + + private void NativeFatReportScope_Changed(object sender, RoutedEventArgs e) + => QueueNativeFatReportPreviewRefresh(); + + private void QueueNativeFatReportPreviewRefresh() + { + if (!_nativeFatReportPreviewEnhanced || _nativeFatPreviewPane?.IsVisible != true || _nativeFatReportRefreshQueued) + return; + + _nativeFatReportRefreshQueued = true; + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(() => + { + _nativeFatReportRefreshQueued = false; + RefreshNativeFatReportPreviewSnapshot(); + })); + } + + private void RefreshNativeFatReportPreviewSnapshot() + { + if (_nativeFatPreviewPane?.IsVisible != true || _nativeFatPreviewGrid == null) + return; + + var device = SelectedDevice; + var state = _nativeFatCurrentState; + if (device == null || state == null || + !state.DeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + _nativeFatReportSnapshot = null; + _nativeFatPreviewGrid.ItemsSource = null; + if (_nativeFatPreviewSummaryText != null) + _nativeFatPreviewSummaryText.Text = "Select an IED with a loaded FAT state."; + if (_nativeFatPreviewEvidenceText != null) + _nativeFatPreviewEvidenceText.Text = "No report evidence is loaded."; + return; + } + + var previousReference = (_nativeFatPreviewGrid.SelectedItem as NativeFatReportRow)?.IecReference; + _nativeFatReportSnapshot = NativeFatReportSnapshotBuilder.Build(device, state); + var showHistorical = _nativeFatShowHistoricalCheck?.IsChecked == true; + var visibleRows = _nativeFatReportSnapshot.Rows + .Where(row => showHistorical || !row.IsHistorical) + .ToArray(); + _nativeFatPreviewGrid.ItemsSource = visibleRows; + + if (_nativeFatPreviewDeviceText != null) + _nativeFatPreviewDeviceText.Text = $"IED · {_nativeFatReportSnapshot.IedName} · {_nativeFatReportSnapshot.IpAddress}:{_nativeFatReportSnapshot.Port}"; + if (_nativeFatPreviewSummaryText != null) + { + var scopeSuffix = !showHistorical && _nativeFatReportSnapshot.HistoricalCount > 0 + ? " · historical hidden" + : string.Empty; + _nativeFatPreviewSummaryText.Text = _nativeFatReportSnapshot.SummaryText + scopeSuffix; + } + + NativeFatReportRow? selection = null; + if (!string.IsNullOrWhiteSpace(previousReference)) + { + selection = visibleRows.FirstOrDefault(row => + row.IecReference.Equals(previousReference, StringComparison.OrdinalIgnoreCase)); + } + selection ??= visibleRows.FirstOrDefault(); + if (selection != null) + { + _nativeFatPreviewGrid.SelectedItem = selection; + _nativeFatPreviewGrid.ScrollIntoView(selection); + } + else + { + UpdateNativeFatPreviewEvidence(null); + } + } + + private void NativeFatPreviewGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) + => UpdateNativeFatPreviewEvidence(_nativeFatPreviewGrid?.SelectedItem as NativeFatReportRow); + + private void UpdateNativeFatPreviewEvidence(NativeFatReportRow? row) + { + if (_nativeFatPreviewEvidenceText == null) + return; + + _nativeFatPreviewEvidenceText.Text = row?.EvidenceSummaryText ?? + "Select a report row to inspect captured evidence."; + } + + private void NativeFatReportPreview_MainWindowClosed(object? sender, EventArgs e) + { + _nativeFatReportPreviewInstallRetry?.Stop(); + if (_nativeFatPreviewGrid != null) + _nativeFatPreviewGrid.SelectionChanged -= NativeFatPreviewGrid_SelectionChanged; + if (_nativeFatPreviewPane != null) + _nativeFatPreviewPane.IsVisibleChanged -= NativeFatPreviewPane_IsVisibleChanged; + _nativeFatRows.CollectionChanged -= NativeFatReportRows_CollectionChanged; + if (_nativeFatShowHistoricalCheck != null) + { + _nativeFatShowHistoricalCheck.Checked -= NativeFatReportScope_Changed; + _nativeFatShowHistoricalCheck.Unchecked -= NativeFatReportScope_Changed; + } + + foreach (var row in _nativeFatReportObservedRows) + row.PropertyChanged -= NativeFatReportRow_PropertyChanged; + _nativeFatReportObservedRows.Clear(); + Closed -= NativeFatReportPreview_MainWindowClosed; + } +} diff --git a/MainWindow.NativeFatWorkspace.cs b/MainWindow.NativeFatWorkspace.cs new file mode 100644 index 000000000..3c28770ea --- /dev/null +++ b/MainWindow.NativeFatWorkspace.cs @@ -0,0 +1,980 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// Native continuous FAT workspace layered on the persistent P0/P1 Engineering shell. +/// +/// Authority boundary: +/// - IED Explorer owns SelectedDevice, signal engineering, live value and acquisition. +/// - the shared Command Dock remains the only command UI/runtime owner. +/// - this workspace owns only FAT captures, result/history and per-IED persistence. +/// +/// The legacy IoListTestingWindow remains untouched as a compatibility fallback while +/// report/evidence parity is migrated incrementally. +/// +public partial class MainWindow +{ + private const int NativeFatWorkspaceIndex = 6; + + private readonly NativeFatStateStore _nativeFatStore = new(); + private readonly ObservableCollection _nativeFatRows = new(); + private readonly Dictionary _nativeFatStateCache = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _nativeFatSaveGate = new(1, 1); + + private DispatcherTimer? _nativeFatInstallRetry; + private DispatcherTimer? _nativeFatReconcileTimer; + private DispatcherTimer? _nativeFatSaveTimer; + private CancellationTokenSource? _nativeFatLoadCts; + private TabItem? _nativeFatTab; + private Button? _nativeFatNavButton; + private DataGrid? _nativeFatGrid; + private DataGrid? _nativeFatPreviewGrid; + private ICollectionView? _nativeFatView; + private TextBox? _nativeFatSearchBox; + private CheckBox? _nativeFatShowHistoricalCheck; + private Border? _nativeFatPreviewPane; + private ColumnDefinition? _nativeFatPreviewGapColumn; + private ColumnDefinition? _nativeFatPreviewColumn; + private TextBlock? _nativeFatContextText; + private TextBlock? _nativeFatStatusText; + private TextBlock? _nativeFatPreviewDeviceText; + private TextBlock? _nativeFatPreviewSummaryText; + private TextBlock? _nativeFatPersistenceText; + private Iec61850MonitorDevice? _nativeFatObservedDevice; + private NativeFatDeviceState? _nativeFatCurrentState; + private bool _nativeFatInstalled; + private bool _nativeFatLoading; + private bool _nativeFatPreviewVisible; + + [ModuleInitializer] + internal static void RegisterNativeFatWorkspace() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatWorkspace_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatWorkspace_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatInstalled) + return; + + // P0 moves MainTabs/Explorer/Command Dock at ContextIdle. Install FAT after that + // re-parenting has settled so this stacked feature never races the stable P0 shell. + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(window.TryInstallNativeFatWorkspace)); + } + + private void TryInstallNativeFatWorkspace() + { + if (_nativeFatInstalled || !IsLoaded) + return; + + if (_persistentWorkbench == null || MainTabs.Items.Count < 6) + { + _nativeFatInstallRetry ??= new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(140) + }; + _nativeFatInstallRetry.Tick -= NativeFatInstallRetry_Tick; + _nativeFatInstallRetry.Tick += NativeFatInstallRetry_Tick; + _nativeFatInstallRetry.Start(); + return; + } + + _nativeFatInstallRetry?.Stop(); + _nativeFatInstalled = true; + + _nativeFatTab = new TabItem + { + Header = "FAT", + Content = BuildNativeFatWorkspaceContent() + }; + MainTabs.Items.Add(_nativeFatTab); + + // The command dock is deliberately shared, not cloned. FAT is command-centric, + // therefore start with the same expanded behavior as Explorer/Event Log. + _persistentWorkbench.DockExpandedByWorkspace[NativeFatWorkspaceIndex] = true; + + InstallNativeFatNavigationButton(); + InstallNativeFatTimers(); + AttachNativeFatObservedDevice(SelectedDevice); + + PropertyChanged += NativeFat_MainWindowPropertyChanged; + MainTabs.SelectionChanged += NativeFat_MainTabsSelectionChanged; + SizeChanged += NativeFat_MainWindowSizeChanged; + Closed += NativeFat_MainWindowClosed; + + QueueNativeFatNavigationGeometry(); + UpdateNativeFatSummary(); + } + + private void NativeFatInstallRetry_Tick(object? sender, EventArgs e) + { + _nativeFatInstallRetry?.Stop(); + TryInstallNativeFatWorkspace(); + } + + private UIElement BuildNativeFatWorkspaceContent() + { + var root = new Grid { Margin = new Thickness(0) }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(8) }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(8) }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var header = BuildNativeFatHeader(); + Grid.SetRow(header, 0); + root.Children.Add(header); + + var toolbar = BuildNativeFatToolbar(); + Grid.SetRow(toolbar, 2); + root.Children.Add(toolbar); + + var body = BuildNativeFatBody(); + Grid.SetRow(body, 4); + root.Children.Add(body); + return root; + } + + private Border BuildNativeFatHeader() + { + var border = new Border + { + Padding = new Thickness(14, 10, 14, 10), + CornerRadius = new CornerRadius(12), + Background = ResourceBrush("SurfaceElevated", Colors.White), + BorderBrush = ResourceBrush("Line", Color.FromRgb(0xDD, 0xE5, 0xF0)), + BorderThickness = new Thickness(1) + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var titleStack = new StackPanel(); + titleStack.Children.Add(new TextBlock + { + Text = "FAT · Continuous Testing", + FontSize = 14.5, + FontWeight = FontWeights.SemiBold, + Foreground = ResourceBrush("Ink", Color.FromRgb(0x20, 0x30, 0x4A)) + }); + titleStack.Children.Add(new TextBlock + { + Text = "Explorer signal authority · live values stay shared · captures autosave per IED · removed signals keep their history", + Margin = new Thickness(0, 3, 0, 0), + FontSize = 10.8, + Foreground = ResourceBrush("Muted", Color.FromRgb(0x66, 0x75, 0x8B)), + TextWrapping = TextWrapping.Wrap + }); + grid.Children.Add(titleStack); + + _nativeFatContextText = new TextBlock + { + Text = "IED · NONE", + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = ResourceBrush("Accent", Color.FromRgb(0x25, 0x63, 0xEB)), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(14, 0, 0, 0) + }; + Grid.SetColumn(_nativeFatContextText, 1); + grid.Children.Add(_nativeFatContextText); + + border.Child = grid; + return border; + } + + private Border BuildNativeFatToolbar() + { + var border = new Border + { + Padding = new Thickness(9, 7, 9, 7), + CornerRadius = new CornerRadius(11), + Background = ResourceBrush("Surface", Colors.White), + BorderBrush = ResourceBrush("Line", Color.FromRgb(0xDD, 0xE5, 0xF0)), + BorderThickness = new Thickness(1) + }; + + var wrap = new WrapPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center + }; + + _nativeFatSearchBox = new TextBox + { + Width = 210, + Height = 30, + Margin = new Thickness(0, 0, 8, 0), + Padding = new Thickness(8, 4, 8, 4), + VerticalContentAlignment = VerticalAlignment.Center, + ToolTip = "Search Signal, IEC reference, type, value, result or history state" + }; + _nativeFatSearchBox.TextChanged += NativeFatSearchBox_TextChanged; + wrap.Children.Add(_nativeFatSearchBox); + + wrap.Children.Add(CreateNativeFatButton("Refresh", NativeFatRefresh_Click, "Reconcile the saved FAT state with the current Explorer signal scope.")); + wrap.Children.Add(CreateNativeFatButton("Capture V1", NativeFatCapture1_Click, "Capture the current Explorer live value into Value 1 for selected FAT rows.")); + wrap.Children.Add(CreateNativeFatButton("Capture V2", NativeFatCapture2_Click, "Capture the current Explorer live value into Value 2 for selected FAT rows.")); + wrap.Children.Add(CreateNativeFatButton("PASS", NativeFatPass_Click, "Mark selected FAT rows PASS; previous results remain in history.")); + wrap.Children.Add(CreateNativeFatButton("REVIEW", NativeFatReview_Click, "Mark selected FAT rows REVIEW; previous results remain in history.")); + wrap.Children.Add(CreateNativeFatButton("FAIL", NativeFatFail_Click, "Mark selected FAT rows FAIL; previous results remain in history.")); + wrap.Children.Add(CreateNativeFatButton("Reset current", NativeFatReset_Click, "Clear current captures/result while retaining the previous state in history.")); + wrap.Children.Add(CreateNativeFatButton("Report Preview", NativeFatReportPreview_Click, "Show or hide the native FAT report preview without opening another FAT runtime.")); + + _nativeFatShowHistoricalCheck = new CheckBox + { + Content = "Show historical", + IsChecked = false, + Margin = new Thickness(7, 6, 7, 0), + VerticalAlignment = VerticalAlignment.Center, + FontSize = 10.7, + Foreground = ResourceBrush("Muted", Color.FromRgb(0x66, 0x75, 0x8B)), + ToolTip = "Show signals removed from the current Explorer/SCL scope. Their previous FAT evidence is never deleted." + }; + _nativeFatShowHistoricalCheck.Checked += NativeFatShowHistorical_Changed; + _nativeFatShowHistoricalCheck.Unchecked += NativeFatShowHistorical_Changed; + wrap.Children.Add(_nativeFatShowHistoricalCheck); + + _nativeFatStatusText = new TextBlock + { + Text = "Select an IED to begin.", + Margin = new Thickness(9, 7, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + FontSize = 10.4, + Foreground = ResourceBrush("Muted", Color.FromRgb(0x66, 0x75, 0x8B)) + }; + wrap.Children.Add(_nativeFatStatusText); + + border.Child = wrap; + return border; + } + + private Grid BuildNativeFatBody() + { + var body = new Grid(); + body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star), MinWidth = 0 }); + _nativeFatPreviewGapColumn = new ColumnDefinition { Width = new GridLength(0) }; + _nativeFatPreviewColumn = new ColumnDefinition { Width = new GridLength(0), MinWidth = 0 }; + body.ColumnDefinitions.Add(_nativeFatPreviewGapColumn); + body.ColumnDefinitions.Add(_nativeFatPreviewColumn); + + _nativeFatGrid = BuildNativeFatGrid(preview: false); + Grid.SetColumn(_nativeFatGrid, 0); + body.Children.Add(_nativeFatGrid); + + _nativeFatPreviewPane = BuildNativeFatPreviewPane(); + _nativeFatPreviewPane.Visibility = Visibility.Collapsed; + Grid.SetColumn(_nativeFatPreviewPane, 2); + body.Children.Add(_nativeFatPreviewPane); + return body; + } + + private DataGrid BuildNativeFatGrid(bool preview) + { + var grid = new DataGrid + { + AutoGenerateColumns = false, + CanUserAddRows = false, + CanUserDeleteRows = false, + CanUserReorderColumns = true, + CanUserResizeColumns = true, + HeadersVisibility = DataGridHeadersVisibility.Column, + IsReadOnly = true, + SelectionMode = preview ? DataGridSelectionMode.Single : DataGridSelectionMode.Extended, + SelectionUnit = DataGridSelectionUnit.FullRow, + GridLinesVisibility = DataGridGridLinesVisibility.Horizontal, + HorizontalGridLinesBrush = ResourceBrush("Line", Color.FromRgb(0xE2, 0xE8, 0xF0)), + BorderBrush = ResourceBrush("Line", Color.FromRgb(0xDD, 0xE5, 0xF0)), + BorderThickness = new Thickness(1), + RowHeight = preview ? 29 : 32, + ColumnHeaderHeight = 31, + Background = ResourceBrush("Surface", Colors.White), + AlternatingRowBackground = new SolidColorBrush(Color.FromRgb(0xFA, 0xFB, 0xFD)), + EnableRowVirtualization = true, + EnableColumnVirtualization = true + }; + + if (TryFindResource("DataGridHeaderCompact") is Style headerStyle) + grid.ColumnHeaderStyle = headerStyle; + if (TryFindResource("DataGridCellCompact") is Style cellStyle) + grid.CellStyle = cellStyle; + + var rowStyle = new Style(typeof(DataGridRow)); + rowStyle.Setters.Add(new Setter(Control.FontSizeProperty, preview ? 10.0 : 10.4)); + var historicalTrigger = new DataTrigger + { + Binding = new Binding(nameof(NativeFatSignalRow.IsHistorical)), + Value = true + }; + historicalTrigger.Setters.Add(new Setter(UIElement.OpacityProperty, 0.56)); + rowStyle.Triggers.Add(historicalTrigger); + grid.RowStyle = rowStyle; + + if (!preview) + { + grid.Columns.Add(TextColumn("SIGNAL", nameof(NativeFatSignalRow.SignalName), 1.25, minWidth: 130)); + grid.Columns.Add(TextColumn("IEC REFERENCE", nameof(NativeFatSignalRow.IecReference), 1.85, minWidth: 190)); + grid.Columns.Add(TextColumn("TYPE", nameof(NativeFatSignalRow.DataType), 0.72, minWidth: 72)); + grid.Columns.Add(TextColumn("LIVE VALUE", nameof(NativeFatSignalRow.LiveValue), 0.9, minWidth: 90)); + grid.Columns.Add(TextColumn("QUALITY", nameof(NativeFatSignalRow.Quality), 0.82, minWidth: 82)); + grid.Columns.Add(TextColumn("VALUE 1", nameof(NativeFatSignalRow.Value1Text), 0.78, minWidth: 78)); + grid.Columns.Add(TextColumn("VALUE 2", nameof(NativeFatSignalRow.Value2Text), 0.78, minWidth: 78)); + grid.Columns.Add(TextColumn("STATUS", nameof(NativeFatSignalRow.StatusText), 0.72, minWidth: 76)); + grid.Columns.Add(TextColumn("RESULT", nameof(NativeFatSignalRow.Result), 0.72, minWidth: 72)); + grid.Columns.Add(TextColumn("HISTORY", nameof(NativeFatSignalRow.HistoryText), 0.78, minWidth: 78)); + } + else + { + grid.Columns.Add(TextColumn("SIGNAL", nameof(NativeFatSignalRow.SignalName), 1.35, minWidth: 115)); + grid.Columns.Add(TextColumn("V1", nameof(NativeFatSignalRow.Value1Text), 0.65, minWidth: 62)); + grid.Columns.Add(TextColumn("V2", nameof(NativeFatSignalRow.Value2Text), 0.65, minWidth: 62)); + grid.Columns.Add(TextColumn("RESULT", nameof(NativeFatSignalRow.Result), 0.72, minWidth: 68)); + } + + return grid; + } + + private Border BuildNativeFatPreviewPane() + { + var border = new Border + { + Padding = new Thickness(11), + CornerRadius = new CornerRadius(12), + Background = ResourceBrush("SurfaceElevated", Colors.White), + BorderBrush = ResourceBrush("Line", Color.FromRgb(0xDD, 0xE5, 0xF0)), + BorderThickness = new Thickness(1) + }; + + var grid = new Grid(); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(8) }); + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + var title = new TextBlock + { + Text = "FAT Report Preview", + FontSize = 13, + FontWeight = FontWeights.SemiBold, + Foreground = ResourceBrush("Ink", Color.FromRgb(0x20, 0x30, 0x4A)) + }; + grid.Children.Add(title); + + _nativeFatPreviewDeviceText = new TextBlock + { + Text = "IED · NONE", + Margin = new Thickness(0, 4, 0, 0), + FontSize = 10.6, + FontWeight = FontWeights.SemiBold, + Foreground = ResourceBrush("Accent", Color.FromRgb(0x25, 0x63, 0xEB)) + }; + Grid.SetRow(_nativeFatPreviewDeviceText, 1); + grid.Children.Add(_nativeFatPreviewDeviceText); + + _nativeFatPreviewSummaryText = new TextBlock + { + Text = "No FAT state loaded.", + Margin = new Thickness(0, 4, 0, 0), + FontSize = 10.2, + Foreground = ResourceBrush("Muted", Color.FromRgb(0x66, 0x75, 0x8B)), + TextWrapping = TextWrapping.Wrap + }; + Grid.SetRow(_nativeFatPreviewSummaryText, 2); + grid.Children.Add(_nativeFatPreviewSummaryText); + + _nativeFatPreviewGrid = BuildNativeFatGrid(preview: true); + Grid.SetRow(_nativeFatPreviewGrid, 4); + grid.Children.Add(_nativeFatPreviewGrid); + + _nativeFatPersistenceText = new TextBlock + { + Text = "Per-IED JSON · non-destructive reconciliation", + Margin = new Thickness(0, 7, 0, 0), + FontSize = 9.5, + Foreground = ResourceBrush("Muted", Color.FromRgb(0x66, 0x75, 0x8B)), + TextWrapping = TextWrapping.Wrap + }; + Grid.SetRow(_nativeFatPersistenceText, 5); + grid.Children.Add(_nativeFatPersistenceText); + + border.Child = grid; + return border; + } + + private DataGridTextColumn TextColumn(string header, string path, double star, double minWidth) + => new() + { + Header = header, + Binding = new Binding(path) { Mode = BindingMode.OneWay }, + Width = new DataGridLength(star, DataGridLengthUnitType.Star), + MinWidth = minWidth + }; + + private Button CreateNativeFatButton(string text, RoutedEventHandler click, string toolTip) + { + var button = new Button + { + Content = text, + Margin = new Thickness(0, 0, 6, 0), + Padding = new Thickness(9, 5, 9, 5), + MinHeight = 29, + FontSize = 10.5, + ToolTip = toolTip + }; + if (TryFindResource("SoftButton") is Style style) + button.Style = style; + button.Click += click; + return button; + } + + private void InstallNativeFatNavigationButton() + { + if (_nativeFatNavButton != null) + return; + + while (WorkflowNavGrid.ColumnDefinitions.Count < 7) + WorkflowNavGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + Grid.SetColumnSpan(WorkflowPill, 7); + _nativeFatNavButton = new Button + { + Name = "NavNativeFatButton", + Content = "FAT", + Tag = NativeFatWorkspaceIndex, + MinWidth = 0, + ToolTip = "Continuous FAT testing using the selected IED and live Explorer signal authority" + }; + if (TryFindResource("SegmentedNavButton") is Style navStyle) + _nativeFatNavButton.Style = navStyle; + _nativeFatNavButton.Click += NativeFatNavButton_Click; + Grid.SetColumn(_nativeFatNavButton, 6); + WorkflowNavGrid.Children.Add(_nativeFatNavButton); + } + + private void InstallNativeFatTimers() + { + _nativeFatReconcileTimer = new DispatcherTimer(DispatcherPriority.Background) + { + Interval = TimeSpan.FromMilliseconds(320) + }; + _nativeFatReconcileTimer.Tick += NativeFatReconcileTimer_Tick; + + _nativeFatSaveTimer = new DispatcherTimer(DispatcherPriority.Background) + { + Interval = TimeSpan.FromMilliseconds(350) + }; + _nativeFatSaveTimer.Tick += NativeFatSaveTimer_Tick; + } + + private async void NativeFatNavButton_Click(object sender, RoutedEventArgs e) + { + if (MainTabs.Items.Count <= NativeFatWorkspaceIndex) + return; + + MainTabs.SelectedIndex = NativeFatWorkspaceIndex; + QueueNativeFatNavigationGeometry(); + await EnsureNativeFatLoadedAsync(forceReconcile: false); + } + + private void NativeFat_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.Source, MainTabs)) + return; + + QueueNativeFatNavigationGeometry(); + if (MainTabs.SelectedIndex == NativeFatWorkspaceIndex) + _ = EnsureNativeFatLoadedAsync(forceReconcile: false); + else + QueueNativeFatSave(); + } + + private void NativeFat_MainWindowSizeChanged(object sender, SizeChangedEventArgs e) + => QueueNativeFatNavigationGeometry(); + + private void QueueNativeFatNavigationGeometry() + { + if (!_nativeFatInstalled) + return; + Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(ApplyNativeFatNavigationGeometry)); + } + + private void ApplyNativeFatNavigationGeometry() + { + if (!_nativeFatInstalled || _nativeFatNavButton == null) + return; + + var availableWidth = ActualWidth > 0d ? ActualWidth : 1480d; + var wide = availableWidth >= 1700d; + var medium = availableWidth >= 1380d; + var shellWidth = wide ? 1085d : medium ? 995d : 805d; + + WorkflowNavShell.Width = shellWidth; + WorkflowNavShell.MinWidth = shellWidth; + WorkflowNavShell.Height = 60; + WorkflowNavShell.Padding = new Thickness(5, 6, 5, 6); + WorkflowNavGrid.ClipToBounds = false; + + while (WorkflowNavGrid.ColumnDefinitions.Count < 7) + WorkflowNavGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + foreach (var column in WorkflowNavGrid.ColumnDefinitions) + column.Width = new GridLength(1, GridUnitType.Star); + + var buttons = new[] + { + NavExplorerButton, + NavLiveButton, + NavEventsButton, + NavAlarmButton, + NavGooseButton, + NavDiagnosticsButton, + _nativeFatNavButton + }; + for (var index = 0; index < buttons.Length; index++) + { + var button = buttons[index]; + button.MinHeight = 40; + button.MinWidth = 0; + button.Margin = new Thickness(1); + button.Padding = wide ? new Thickness(9, 7, 9, 7) : new Thickness(5, 7, 5, 7); + button.HorizontalContentAlignment = HorizontalAlignment.Center; + button.VerticalContentAlignment = VerticalAlignment.Center; + button.Foreground = index == MainTabs.SelectedIndex + ? Brushes.White + : ResourceBrush("Muted", Color.FromRgb(0x5F, 0x6B, 0x7A)); + } + + Grid.SetColumnSpan(WorkflowPill, 7); + var contentWidth = Math.Max(0d, shellWidth - WorkflowNavShell.Padding.Left - WorkflowNavShell.Padding.Right); + var cellWidth = contentWidth / 7d; + WorkflowPill.Width = Math.Max(1d, cellWidth - 2d); + WorkflowPill.Height = 36; + + WorkflowPillTranslate.BeginAnimation(TranslateTransform.XProperty, null); + WorkflowPillTranslate.X = Math.Clamp(MainTabs.SelectedIndex, 0, NativeFatWorkspaceIndex) * cellWidth; + } + + private void NativeFat_MainWindowPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedDevice)) + return; + + QueueNativeFatSave(); + AttachNativeFatObservedDevice(SelectedDevice); + if (MainTabs.SelectedIndex == NativeFatWorkspaceIndex) + _ = EnsureNativeFatLoadedAsync(forceReconcile: true); + else + UpdateNativeFatSummary(); + } + + private void AttachNativeFatObservedDevice(Iec61850MonitorDevice? device) + { + if (ReferenceEquals(_nativeFatObservedDevice, device)) + return; + + if (_nativeFatObservedDevice != null) + { + _nativeFatObservedDevice.Signals.CollectionChanged -= NativeFatSignalCollectionChanged; + _nativeFatObservedDevice.Points.CollectionChanged -= NativeFatSignalCollectionChanged; + } + + _nativeFatObservedDevice = device; + if (_nativeFatObservedDevice != null) + { + _nativeFatObservedDevice.Signals.CollectionChanged += NativeFatSignalCollectionChanged; + _nativeFatObservedDevice.Points.CollectionChanged += NativeFatSignalCollectionChanged; + } + } + + private void NativeFatSignalCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + _nativeFatReconcileTimer?.Stop(); + _nativeFatReconcileTimer?.Start(); + } + + private async void NativeFatReconcileTimer_Tick(object? sender, EventArgs e) + { + _nativeFatReconcileTimer?.Stop(); + await EnsureNativeFatLoadedAsync(forceReconcile: true); + } + + private async Task EnsureNativeFatLoadedAsync(bool forceReconcile) + { + if (!_nativeFatInstalled || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + var device = SelectedDevice; + if (device == null) + { + ClearNativeFatRows(); + _nativeFatCurrentState = null; + UpdateNativeFatSummary(); + return; + } + + var cacheKey = StableNativeFatDeviceKey(device); + if (!forceReconcile && _nativeFatCurrentState != null && + _nativeFatCurrentState.DeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + UpdateNativeFatSummary(); + return; + } + + _nativeFatLoadCts?.Cancel(); + _nativeFatLoadCts?.Dispose(); + _nativeFatLoadCts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); + var token = _nativeFatLoadCts.Token; + _nativeFatLoading = true; + SetNativeFatStatus($"Loading FAT state for {device.Name}…"); + + try + { + NativeFatDeviceState state; + if (_nativeFatStateCache.TryGetValue(cacheKey, out var cached)) + { + state = cached; + } + else + { + state = await _nativeFatStore.LoadAsync(device, token); + token.ThrowIfCancellationRequested(); + _nativeFatStateCache[cacheKey] = state; + } + + // We are back on the WPF dispatcher here. Reconciliation enumerates Explorer + // ObservableCollections only on their owning UI thread. + token.ThrowIfCancellationRequested(); + if (!ReferenceEquals(device, SelectedDevice)) + return; + + NativeFatStateStore.Reconcile(state, device); + _nativeFatCurrentState = state; + RebuildNativeFatRows(state, device); + QueueNativeFatSave(); + SetNativeFatStatus($"FAT ready · {device.Name}"); + } + catch (OperationCanceledException) + { + // Fast IED switches are normal; stale loads are intentionally discarded. + } + catch (Exception ex) + { + AddLog("WARN", "Native FAT", $"FAT state load/reconcile failed: {ex.Message}"); + SetNativeFatStatus("FAT state could not be loaded. Explorer monitoring remains unaffected."); + } + finally + { + _nativeFatLoading = false; + UpdateNativeFatSummary(); + } + } + + private void RebuildNativeFatRows(NativeFatDeviceState state, Iec61850MonitorDevice device) + { + ClearNativeFatRows(); + foreach (var row in NativeFatStateStore.BuildRows(state, device)) + { + row.StateChanged += NativeFatRow_StateChanged; + _nativeFatRows.Add(row); + } + + _nativeFatView = CollectionViewSource.GetDefaultView(_nativeFatRows); + _nativeFatView.Filter = NativeFatViewFilter; + if (_nativeFatGrid != null) + _nativeFatGrid.ItemsSource = _nativeFatView; + if (_nativeFatPreviewGrid != null) + _nativeFatPreviewGrid.ItemsSource = _nativeFatRows; + UpdateNativeFatSummary(); + } + + private void ClearNativeFatRows() + { + foreach (var row in _nativeFatRows) + { + row.StateChanged -= NativeFatRow_StateChanged; + row.Dispose(); + } + _nativeFatRows.Clear(); + _nativeFatView = null; + if (_nativeFatGrid != null) + _nativeFatGrid.ItemsSource = null; + if (_nativeFatPreviewGrid != null) + _nativeFatPreviewGrid.ItemsSource = null; + } + + private bool NativeFatViewFilter(object item) + { + if (item is not NativeFatSignalRow row) + return false; + + if (row.IsHistorical && _nativeFatShowHistoricalCheck?.IsChecked != true) + return false; + + var query = _nativeFatSearchBox?.Text?.Trim(); + if (string.IsNullOrWhiteSpace(query)) + return true; + + return row.SignalName.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.IecReference.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.IecTelegram.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.DataType.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.LiveValue.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.Value1Text.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.Value2Text.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.Result.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.StatusText.Contains(query, StringComparison.OrdinalIgnoreCase); + } + + private void NativeFatSearchBox_TextChanged(object sender, TextChangedEventArgs e) + => _nativeFatView?.Refresh(); + + private void NativeFatShowHistorical_Changed(object sender, RoutedEventArgs e) + { + _nativeFatView?.Refresh(); + UpdateNativeFatSummary(); + } + + private void NativeFatRow_StateChanged(object? sender, EventArgs e) + { + QueueNativeFatSave(); + UpdateNativeFatSummary(); + _nativeFatPreviewGrid?.Items.Refresh(); + } + + private IReadOnlyList SelectedNativeFatRows() + { + if (_nativeFatGrid == null) + return Array.Empty(); + + var selected = _nativeFatGrid.SelectedItems.OfType().ToArray(); + if (selected.Length > 0) + return selected; + return _nativeFatGrid.CurrentItem is NativeFatSignalRow current + ? new[] { current } + : Array.Empty(); + } + + private void NativeFatRefresh_Click(object sender, RoutedEventArgs e) + => _ = EnsureNativeFatLoadedAsync(forceReconcile: true); + + private void NativeFatCapture1_Click(object sender, RoutedEventArgs e) + => CaptureNativeFatSelection(1); + + private void NativeFatCapture2_Click(object sender, RoutedEventArgs e) + => CaptureNativeFatSelection(2); + + private void CaptureNativeFatSelection(int slot) + { + var rows = SelectedNativeFatRows(); + if (rows.Count == 0) + { + SetNativeFatStatus("Select one or more FAT signals first."); + return; + } + + var captured = 0; + foreach (var row in rows) + { + if (row.CaptureValue(slot)) + captured++; + } + SetNativeFatStatus(captured == 0 + ? "No selected row has a current Explorer signal source. Historical rows cannot be captured." + : $"Captured Value {slot} for {captured} signal(s). Autosave queued."); + } + + private void NativeFatPass_Click(object sender, RoutedEventArgs e) + => SetNativeFatSelectionResult(NativeFatResult.Pass); + + private void NativeFatReview_Click(object sender, RoutedEventArgs e) + => SetNativeFatSelectionResult(NativeFatResult.Review); + + private void NativeFatFail_Click(object sender, RoutedEventArgs e) + => SetNativeFatSelectionResult(NativeFatResult.Fail); + + private void SetNativeFatSelectionResult(string result) + { + var rows = SelectedNativeFatRows(); + if (rows.Count == 0) + { + SetNativeFatStatus("Select one or more FAT signals first."); + return; + } + + foreach (var row in rows) + row.SetResult(result); + SetNativeFatStatus($"{rows.Count} signal(s) marked {result}. Previous state retained in history."); + } + + private void NativeFatReset_Click(object sender, RoutedEventArgs e) + { + var rows = SelectedNativeFatRows(); + if (rows.Count == 0) + { + SetNativeFatStatus("Select one or more FAT signals first."); + return; + } + + foreach (var row in rows) + row.ResetCurrentResult(); + SetNativeFatStatus($"Reset current FAT state for {rows.Count} signal(s); history was retained."); + } + + private void NativeFatReportPreview_Click(object sender, RoutedEventArgs e) + { + _nativeFatPreviewVisible = !_nativeFatPreviewVisible; + if (_nativeFatPreviewPane == null || _nativeFatPreviewGapColumn == null || _nativeFatPreviewColumn == null) + return; + + _nativeFatPreviewPane.Visibility = _nativeFatPreviewVisible ? Visibility.Visible : Visibility.Collapsed; + _nativeFatPreviewGapColumn.Width = new GridLength(_nativeFatPreviewVisible ? 10 : 0); + _nativeFatPreviewColumn.Width = _nativeFatPreviewVisible ? new GridLength(330) : new GridLength(0); + UpdateNativeFatSummary(); + } + + private void QueueNativeFatSave() + { + if (_nativeFatCurrentState == null || _nativeFatSaveTimer == null) + return; + _nativeFatSaveTimer.Stop(); + _nativeFatSaveTimer.Start(); + } + + private async void NativeFatSaveTimer_Tick(object? sender, EventArgs e) + { + _nativeFatSaveTimer?.Stop(); + if (_nativeFatCurrentState != null) + await SaveNativeFatStateAsync(_nativeFatCurrentState); + } + + private async Task SaveNativeFatStateAsync(NativeFatDeviceState state) + { + try + { + await _nativeFatSaveGate.WaitAsync(_applicationCancellation.Token); + try + { + await _nativeFatStore.SaveAsync(state, _applicationCancellation.Token); + } + finally + { + _nativeFatSaveGate.Release(); + } + + if (ReferenceEquals(state, _nativeFatCurrentState)) + { + SetNativeFatStatus($"Saved · {DateTime.Now:HH:mm:ss}"); + UpdateNativeFatSummary(); + } + } + catch (OperationCanceledException) + { + // Application shutdown or superseded state. + } + catch (Exception ex) + { + AddLog("WARN", "Native FAT", $"Autosave failed: {ex.Message}"); + if (ReferenceEquals(state, _nativeFatCurrentState)) + SetNativeFatStatus("Autosave failed; current in-memory FAT state is still available."); + } + } + + private void UpdateNativeFatSummary() + { + var device = SelectedDevice; + var deviceLabel = device == null ? "NONE" : device.Name; + if (_nativeFatContextText != null) + _nativeFatContextText.Text = $"IED · {deviceLabel}"; + if (_nativeFatPreviewDeviceText != null) + _nativeFatPreviewDeviceText.Text = $"IED · {deviceLabel}"; + + var current = _nativeFatRows.Count(row => !row.IsHistorical); + var historical = _nativeFatRows.Count(row => row.IsHistorical); + var pass = _nativeFatRows.Count(row => !row.IsHistorical && row.Result == NativeFatResult.Pass); + var review = _nativeFatRows.Count(row => !row.IsHistorical && row.Result == NativeFatResult.Review); + var fail = _nativeFatRows.Count(row => !row.IsHistorical && row.Result == NativeFatResult.Fail); + var untested = Math.Max(0, current - pass - review - fail); + + if (_nativeFatPreviewSummaryText != null) + { + _nativeFatPreviewSummaryText.Text = device == null + ? "Select an IED in the persistent Explorer." + : $"Current {current} · PASS {pass} · REVIEW {review} · FAIL {fail} · UNTESTED {untested} · historical {historical}"; + } + + if (_nativeFatPersistenceText != null) + { + _nativeFatPersistenceText.Text = _nativeFatCurrentState == null + ? "Per-IED JSON · non-destructive reconciliation" + : $"Resume file: {Path.GetFileName(_nativeFatCurrentState.StoragePath)}\nHistorical IEC identities remain stored when engineering changes."; + } + + if (!_nativeFatLoading && device != null && _nativeFatStatusText != null && + (_nativeFatStatusText.Text.StartsWith("Select", StringComparison.OrdinalIgnoreCase) || + _nativeFatStatusText.Text.StartsWith("Loading", StringComparison.OrdinalIgnoreCase))) + { + _nativeFatStatusText.Text = $"{current} current · {historical} historical · {untested} untested"; + } + } + + private void SetNativeFatStatus(string text) + { + if (_nativeFatStatusText != null) + _nativeFatStatusText.Text = text; + } + + private static string StableNativeFatDeviceKey(Iec61850MonitorDevice device) + => !string.IsNullOrWhiteSpace(device.DeviceId) ? device.DeviceId : device.Name; + + private Brush ResourceBrush(string key, Color fallback) + => TryFindResource(key) as Brush ?? new SolidColorBrush(fallback); + + private void NativeFat_MainWindowClosed(object? sender, EventArgs e) + { + _nativeFatInstallRetry?.Stop(); + _nativeFatReconcileTimer?.Stop(); + _nativeFatSaveTimer?.Stop(); + _nativeFatLoadCts?.Cancel(); + _nativeFatLoadCts?.Dispose(); + + PropertyChanged -= NativeFat_MainWindowPropertyChanged; + MainTabs.SelectionChanged -= NativeFat_MainTabsSelectionChanged; + SizeChanged -= NativeFat_MainWindowSizeChanged; + Closed -= NativeFat_MainWindowClosed; + + if (_nativeFatObservedDevice != null) + { + _nativeFatObservedDevice.Signals.CollectionChanged -= NativeFatSignalCollectionChanged; + _nativeFatObservedDevice.Points.CollectionChanged -= NativeFatSignalCollectionChanged; + } + + ClearNativeFatRows(); + _nativeFatSaveGate.Dispose(); + } +} diff --git a/MainWindow.ProductionFatEngineeringBootstrap.cs b/MainWindow.ProductionFatEngineeringBootstrap.cs new file mode 100644 index 000000000..f7c8dfb36 --- /dev/null +++ b/MainWindow.ProductionFatEngineeringBootstrap.cs @@ -0,0 +1,194 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Makes the embedded production FAT workspace a projection of the Engineering workspace. +/// Opening SCL in Explorer is therefore sufficient: selecting FAT reuses the already-parsed +/// ARIEC SCL/static DataSet authority and the existing Engineering acquisition session. +/// +public partial class MainWindow +{ + private bool _productionFatEngineeringBootstrapInstalled; + private bool _productionFatEngineeringBootstrapBusy; + private CancellationTokenSource? _productionFatEngineeringBootstrapCts; + + [ModuleInitializer] + internal static void RegisterProductionFatEngineeringBootstrap() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(ProductionFatEngineeringBootstrap_Loaded), + handledEventsToo: true); + } + + private static void ProductionFatEngineeringBootstrap_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._productionFatEngineeringBootstrapInstalled) + return; + + window._productionFatEngineeringBootstrapInstalled = true; + window.MainTabs.SelectionChanged += window.ProductionFatEngineeringBootstrap_SelectionChanged; + window.PropertyChanged += window.ProductionFatEngineeringBootstrap_PropertyChanged; + window.Closed += window.ProductionFatEngineeringBootstrap_Closed; + window.QueueProductionFatEngineeringBootstrap(); + } + + private void ProductionFatEngineeringBootstrap_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.Source, MainTabs) || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + QueueProductionFatEngineeringBootstrap(); + } + + private void ProductionFatEngineeringBootstrap_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedDevice) || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + // A running production FAT session owns its latched IED. The normal embedded-host + // selection bridge handles idle retargeting. Bootstrap is only needed before a FAT + // workspace exists. + if (_productionFatWindow == null && _loadedIoFatWindow == null) + QueueProductionFatEngineeringBootstrap(); + } + + private void QueueProductionFatEngineeringBootstrap() + { + if (!_productionFatEngineeringBootstrapInstalled || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(async () => await EnsureProductionFatFromEngineeringAsync())); + } + + private async Task EnsureProductionFatFromEngineeringAsync() + { + if (_productionFatEngineeringBootstrapBusy || + MainTabs.SelectedIndex != NativeFatWorkspaceIndex || + !ProductionFatTabReady) + { + return; + } + + if (_productionFatWindow is { IsLoaded: true } || _loadedIoFatWindow is { IsLoaded: true }) + { + SynchronizeProductionFatSelectedIed(); + return; + } + + var selected = SelectedDevice; + if (selected?.SclWorkspace == null || + selected.SclWorkspace.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) == 0) + { + SetStatus(selected == null + ? "FAT · select an Engineering IED with a static DataSet." + : $"FAT · {selected.Name} has no static DataSet scope in the Engineering SCL model."); + return; + } + + var engineeringDevices = Devices + .Where(device => device.SclWorkspace != null) + .Where(device => device.SclWorkspace!.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) > 0) + .Where(device => !string.IsNullOrWhiteSpace(device.SclSourcePath)) + .ToArray(); + if (engineeringDevices.All(device => !ReferenceEquals(device, selected))) + { + SetStatus($"FAT · Engineering source provenance for {selected.Name} is unavailable; use Open SCL to restore the source authority."); + return; + } + + _productionFatEngineeringBootstrapBusy = true; + _productionFatEngineeringBootstrapCts?.Cancel(); + _productionFatEngineeringBootstrapCts?.Dispose(); + _productionFatEngineeringBootstrapCts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); + var token = _productionFatEngineeringBootstrapCts.Token; + SetStatus($"FAT · preparing {selected.Name} from the Engineering static DataSet…"); + + try + { + var projection = await IoFatEngineeringWorkspaceProjectionService.BuildAsync( + engineeringDevices, + token); + token.ThrowIfCancellationRequested(); + + // Register the exact same ARIEC workspace instances already owned by Explorer. + // Production FAT preparation can therefore prove shared SCL authority without + // reparsing XML or starting a second model/acquisition stack. + _ioFatSclProjectImportService.AdoptEngineeringRuntimeWorkspaces(projection.RuntimeWorkspaces); + + var launch = await IoTestWorkspaceBootstrapService.OpenSourcesAsync( + projection.Project, + projection.SourceInputs, + IoTestingProjectsRoot(), + IoTestingEvidenceRoot(), + CreateIoTestSession, + token); + token.ThrowIfCancellationRequested(); + + SynchronizeImportedSclFatWithEngineering(launch.Project); + + // This automatic entry path is explicitly Static DataSet FAT. Engineering may + // also expose selected scalar aliases outside the DataSet, and older saved P2 + // projects may contain scl-manual-* rows created from those aliases. Keep such + // rows/evidence in the project for audit continuity, but do not arm them in the + // shared workspace here. Otherwise a static member and its scalar alias can both + // resolve to the same live primary leaf and correctly trip session preflight. + var retiredManualRows = launch.Project.Ieds.Sum( + IoFatEngineeringSelectionBridge.RetireManualWorkspaceRowsForStaticDataSetMode); + if (retiredManualRows > 0) + { + AddLog( + "INFO", + "FAT", + $"Automatic Static DataSet scope retired {retiredManualRows} manual SCL workspace overlay(s); static membership remains authoritative."); + } + + RegisterSharedSclSourcePaths(launch.Project, launch.Project.Ieds, projection.SourceInputs); + foreach (var ied in launch.Project.Ieds) + { + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + if (device is not null) + MarkSharedSelectionAuthority(device); + } + + launch.Workspace.ScheduleSave(); + await ShowIoTestingWorkspaceAsync(launch, importWarningCount: 0); + SynchronizeProductionFatSelectedIed(); + SetStatus($"FAT ready · {selected.Name} · Engineering static DataSet authority reused · no SCL re-import."); + } + catch (OperationCanceledException) + { + // Fast navigation/close is normal. No modal interruption is appropriate here. + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) + { + AddLog("WARN", "FAT", $"Automatic Engineering FAT bootstrap unavailable: {ex.Message}"); + SetStatus($"FAT · could not reuse the Engineering static DataSet automatically: {ex.Message}"); + } + finally + { + _productionFatEngineeringBootstrapBusy = false; + } + } + + private void ProductionFatEngineeringBootstrap_Closed(object? sender, EventArgs e) + { + MainTabs.SelectionChanged -= ProductionFatEngineeringBootstrap_SelectionChanged; + PropertyChanged -= ProductionFatEngineeringBootstrap_PropertyChanged; + Closed -= ProductionFatEngineeringBootstrap_Closed; + _productionFatEngineeringBootstrapCts?.Cancel(); + _productionFatEngineeringBootstrapCts?.Dispose(); + _productionFatEngineeringBootstrapCts = null; + } +} diff --git a/MainWindow.ProductionFatNavigationParity.cs b/MainWindow.ProductionFatNavigationParity.cs new file mode 100644 index 000000000..61787678d --- /dev/null +++ b/MainWindow.ProductionFatNavigationParity.cs @@ -0,0 +1,173 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// Extends the existing six-destination responsive nav contract to the dynamically-added +/// production FAT destination. The legacy MainWindow navigation code clamps index 6 to 5; +/// this late correction keeps Diagnostics from remaining highlighted while FAT is active +/// and moves the same selection pill into the seventh equal-width cell. +/// +internal static class MainWindowProductionFatNavigationParity +{ + private static readonly string[] NavigationButtonNames = + [ + "NavExplorerButton", + "NavLiveButton", + "NavEventsButton", + "NavAlarmButton", + "NavGooseButton", + "NavDiagnosticsButton", + "NavNativeFatButton" + ]; + + [ModuleInitializer] + internal static void Register() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(OnLoaded), + handledEventsToo: true); + EventManager.RegisterClassHandler( + typeof(MainWindow), + Button.ClickEvent, + new RoutedEventHandler(OnButtonClick), + handledEventsToo: true); + } + + private static void OnLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window) + return; + + window.SizeChanged -= Window_SizeChanged; + window.SizeChanged += Window_SizeChanged; + if (window.FindName("MainTabs") is TabControl tabs) + { + tabs.SelectionChanged -= Tabs_SelectionChanged; + tabs.SelectionChanged += Tabs_SelectionChanged; + } + Queue(window, animate: false); + } + + private static void OnButtonClick(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || e.Source is not Button button || + !NavigationButtonNames.Contains(button.Name, StringComparer.Ordinal)) + { + return; + } + Queue(window, animate: true); + } + + private static void Tabs_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not TabControl tabs || !ReferenceEquals(e.Source, tabs) || + Window.GetWindow(tabs) is not MainWindow window) + { + return; + } + Queue(window, animate: true); + } + + private static void Window_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (sender is MainWindow window) + Queue(window, animate: false); + } + + private static void Queue(MainWindow window, bool animate) + { + // Existing MainWindow + responsive-layout handlers still perform their historical + // six-slot correction. ApplicationIdle deliberately runs after those handlers so + // the seven-slot Engineering shell is the final visual authority. + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(() => Apply(window, animate))); + } + + private static void Apply(MainWindow window, bool animate) + { + if (window.FindName("MainTabs") is not TabControl tabs || tabs.Items.Count < 7 || + window.FindName("WorkflowNavGrid") is not Grid grid || + window.FindName("WorkflowNavShell") is not Border shell || + window.FindName("WorkflowPill") is not Border pill) + { + return; + } + + var fatButton = grid.Children + .OfType