From 87c619f2695de2fb3b5712d2975a4b3b37fd1e04 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:23:04 +0700 Subject: [PATCH 01/21] test(goose): introduce stable keyed publisher reconciliation and deterministic burst tests --- .../Presentation/GoosePublisherCatalog.cs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs diff --git a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs new file mode 100644 index 00000000..95a12a81 --- /dev/null +++ b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs @@ -0,0 +1,140 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using ProcessBus.Core.Models; + +namespace ProcessBus.Core.Presentation; + +/// +/// Protocol identity of a GOOSE control block on a particular captured LAN. +/// Sequence numbers, reception time, ConfRev and DataSet values are NOT identity. +/// GoCB references are case-sensitive IEC 61850 object references. +/// +public readonly record struct GoosePublisherKey(string SourceMac, string AppId, string GoCbRef, string VlanId) +{ + public static GoosePublisherKey From(GooseMessageItem message) => new( + NormalizeMac(message.SourceMac), + (message.AppId ?? string.Empty).Trim().ToUpperInvariant(), + (message.GoCbRef ?? string.Empty).Trim(), + (message.VlanId ?? string.Empty).Trim()); + + private static string NormalizeMac(string? mac) => + (mac ?? string.Empty).Trim().Replace('-', ':').ToUpperInvariant(); +} + +/// +/// Stable WPF item container identity. The receive snapshot is replaced atomically, +/// while the row instance is preserved; only changed visible fields are notified. +/// +public sealed class GoosePublisherRow : INotifyPropertyChanged +{ + public GoosePublisherRow(GoosePublisherKey key, GooseMessageItem current) + { + Key = key; + Current = current; + } + + public GoosePublisherKey Key { get; } + public GooseMessageItem Current { get; private set; } + public bool IsStale { get; private set; } + public string GoId => Current.GoId; + public string GoCbRef => Current.GoCbRef; + public string AppId => Current.AppId; + public uint StNum => Current.StNum; + public uint SqNum => Current.SqNum; + + public event PropertyChangedEventHandler? PropertyChanged; + + public bool Update(GooseMessageItem message) + { + if (Key != GoosePublisherKey.From(message)) + throw new ArgumentException("Publisher identity mismatch.", nameof(message)); + + var before = Current; + var changed = !ReferenceEquals(before, message); + Current = message; + if (IsStale) + { + IsStale = false; + OnPropertyChanged(nameof(IsStale)); + } + if (!changed) + return false; + + if (before.GoId != message.GoId) OnPropertyChanged(nameof(GoId)); + if (before.GoCbRef != message.GoCbRef) OnPropertyChanged(nameof(GoCbRef)); + if (before.AppId != message.AppId) OnPropertyChanged(nameof(AppId)); + if (before.StNum != message.StNum) OnPropertyChanged(nameof(StNum)); + if (before.SqNum != message.SqNum) OnPropertyChanged(nameof(SqNum)); + return true; + } + + internal void MarkStale() + { + if (IsStale) return; + IsStale = true; + OnPropertyChanged(nameof(IsStale)); + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} + +/// +/// Bounded keyed reconciliation on the UI dispatcher, never on the capture hot path. +/// An absent selected publisher retains its last coherent frame as stale; unrelated +/// evicted publishers are removed without resetting the selected item container. +/// +public sealed class GoosePublisherCatalog +{ + private const int MaxPublisherRows = 256; + private readonly Dictionary _byKey = new(); + public ObservableCollection Rows { get; } = new(); + + public GoosePublisherRow? Find(GoosePublisherKey? key) => + key.HasValue && _byKey.TryGetValue(key.Value, out var row) ? row : null; + + public bool Reconcile(IReadOnlyList messages, GoosePublisherKey? selected) + { + var seen = new HashSet(); + var changed = false; + foreach (var message in messages) + { + var key = GoosePublisherKey.From(message); + seen.Add(key); + if (_byKey.TryGetValue(key, out var row)) + { + changed |= row.Update(message); + } + else if (Rows.Count < MaxPublisherRows) + { + row = new GoosePublisherRow(key, message); + _byKey.Add(key, row); + Rows.Add(row); + changed = true; + } + } + + // Only remove truly absent non-selected rows. Do not rebuild the whole list. + for (var i = Rows.Count - 1; i >= 0; i--) + { + var row = Rows[i]; + if (seen.Contains(row.Key)) continue; + if (selected.HasValue && selected.Value == row.Key) + { + row.MarkStale(); + continue; + } + _byKey.Remove(row.Key); + Rows.RemoveAt(i); + changed = true; + } + return changed; + } + + public void Clear() + { + _byKey.Clear(); + Rows.Clear(); + } +} From db1c6096fad30bbc80a4307edb7ab6f601434f43 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:23:07 +0700 Subject: [PATCH 02/21] test(goose): introduce stable keyed publisher reconciliation and deterministic burst tests --- .../GoosePublisherSelectionTests.cs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs diff --git a/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs b/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs new file mode 100644 index 00000000..f8625faa --- /dev/null +++ b/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs @@ -0,0 +1,114 @@ +using ProcessBus.Core.Models; +using ProcessBus.Core.Presentation; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class GoosePublisherSelectionTests +{ + private static GooseMessageItem Frame( + string source = "02:00:4C:4E:4E:50", + string goCb = "IED/LLN0$GO$gcb", + uint st = 1, uint sq = 1, string vlan = "0") => new() + { + MessageId = $"{source}|{goCb}", + SourceMac = source, + GoCbRef = goCb, + GoId = "SharedGoID", + AppId = "0x0001", + VlanId = vlan, + StNum = st, + SqNum = sq, + DataValues = [new GooseDatasetValueItem { Index = 1, Name = "stVal", Type = "Boolean", Value = st.ToString() }], + LastSeenUtc = new DateTime(2026, 10, 21, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(sq) + }; + + [Fact] + public void RetransmissionsKeepSameRowAndExposeLatestTypedFrame() + { + var catalog = new GoosePublisherCatalog(); + var first = Frame(); + var key = GoosePublisherKey.From(first); + catalog.Reconcile([first], null); + var selected = Assert.Single(catalog.Rows); + var resets = 0; + catalog.Rows.CollectionChanged += (_, _) => resets++; + + for (uint sq = 2; sq <= 1000; sq++) + catalog.Reconcile([Frame(sq: sq)], key); + + Assert.Same(selected, catalog.Find(key)); + Assert.Equal((uint)1000, selected.Current.SqNum); + Assert.Single(selected.Current.DataValues); + Assert.Equal(0, resets); + Assert.False(selected.IsStale); + } + + [Fact] + public void SameGoIdAcrossMacAndVlanNeverCoalesces() + { + var catalog = new GoosePublisherCatalog(); + catalog.Reconcile([Frame(), Frame(source: "02:00:4C:4E:4E:51"), Frame(vlan: "7")], null); + Assert.Equal(3, catalog.Rows.Count); + } + + [Fact] + public void SelectedPublisherSurvivesAlternatingTrafficAndTemporaryAbsence() + { + var catalog = new GoosePublisherCatalog(); + var a = Frame(); + var b = Frame(source: "02:00:4C:4E:4E:51"); + var key = GoosePublisherKey.From(a); + catalog.Reconcile([a,b], null); + var selected = catalog.Find(key); + catalog.Reconcile([Frame(source: b.SourceMac, sq: 10)], key); + Assert.Same(selected, catalog.Find(key)); + Assert.True(selected!.IsStale); + Assert.Single(catalog.Rows); + catalog.Reconcile([Frame(sq: 30), Frame(source: b.SourceMac, sq: 11)], key); + Assert.Same(selected, catalog.Find(key)); + Assert.False(selected!.IsStale); + Assert.Equal((uint)30, selected.Current.SqNum); + } + + [Fact] + public void StateNumbersAndDataSetDoNotBecomePublisherIdentity() + { + var catalog = new GoosePublisherCatalog(); + var a = Frame(st: 1, sq: 2); + var k = GoosePublisherKey.From(a); + catalog.Reconcile([a], null); + var same = catalog.Find(k); + catalog.Reconcile([Frame(st: 15, sq: 5)], k); + Assert.Same(same, catalog.Find(k)); + Assert.Equal((uint)15, same!.Current.StNum); + Assert.Equal("15", same.Current.DataValues[0].Value); + } + + [Fact] + public void ExplicitClearIsDifferentFromTransientEmptySnapshot() + { + var catalog = new GoosePublisherCatalog(); + var a = Frame(); + var key = GoosePublisherKey.From(a); + catalog.Reconcile([a], key); + catalog.Reconcile([], key); + Assert.True(catalog.Find(key)!.IsStale); + catalog.Clear(); + Assert.Empty(catalog.Rows); + Assert.Null(catalog.Find(key)); + } + + [Fact] + public void HighTrafficDoesNotCauseUnboundedRows() + { + var catalog = new GoosePublisherCatalog(); + var frames = Enumerable.Range(0, 500) + .Select(i => Frame(source: $"02:00:00:{i / 65536:X2}:{i / 256 % 256:X2}:{i % 256:X2}")) + .ToArray(); + catalog.Reconcile(frames, null); + Assert.InRange(catalog.Rows.Count, 1, 256); + catalog.Reconcile([], null); + Assert.Empty(catalog.Rows); + } +} From 6d5244f514cb0a0e59373ba60115b35d7d861cb4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:24:45 +0700 Subject: [PATCH 03/21] fix(goose): preserve keyed publisher selection and independent coherent inspector mode --- .../ViewModels/MainWindowViewModel.cs | 169 ++++++++++++------ 1 file changed, 114 insertions(+), 55 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 24a460a1..9d996938 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -44,7 +44,11 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private string _selectedAdapterId = string.Empty; private SvStreamItem? _selectedStream; private GooseMessageItem? _selectedGooseMessage; + private readonly GoosePublisherCatalog _gooseCatalog = new(); + private GoosePublisherKey? _selectedGoosePublisherKey; private GooseTrafficRow? _selectedGooseTrafficRow; + private bool _gooseInspectHistoricalEvent; + private IReadOnlyList _gooseIdFilterOptions = ["All"]; private IReadOnlyList _gooseMessages = Array.Empty(); private GooseMonitorSnapshot _gooseSnapshot = new(); private IReadOnlyList _phasors = Array.Empty(); @@ -123,7 +127,7 @@ private set ? invalid.Description : "Select a real Npcap capture adapter."; private readonly ObservableCollection _gooseHistory = new(); - private readonly Dictionary _lastGooseState = new(); + private readonly Dictionary _lastGooseState = new(); private readonly Queue _pendingGooseRows = new(); private readonly Queue _pendingDiagnosticEvents = new(); private readonly ObservableCollection _calmDiagnosticEvents = new(); @@ -233,20 +237,22 @@ public string GoosePublisherFilterText } } - public IReadOnlyList GooseIdFilterOptions - { - get - { - var values = _gooseMessages - .Select(m => string.IsNullOrWhiteSpace(m.GoId) ? m.GoCbRef : m.GoId) - .Where(v => !string.IsNullOrWhiteSpace(v) && !string.Equals(v, "N/A", StringComparison.OrdinalIgnoreCase)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) - .ToList(); + // Replace ComboBox options only if identities change, not with each UI tick. + public IReadOnlyList GooseIdFilterOptions => _gooseIdFilterOptions; - values.Insert(0, "All"); - return values; - } + private void UpdateGooseIdFilterOptions() + { + var values = _gooseMessages + .Select(m => string.IsNullOrWhiteSpace(m.GoId) ? m.GoCbRef : m.GoId) + .Where(v => !string.IsNullOrWhiteSpace(v) && !string.Equals(v, "N/A", StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) + .Prepend("All") + .ToArray(); + if (_gooseIdFilterOptions.SequenceEqual(values, StringComparer.Ordinal)) + return; + _gooseIdFilterOptions = values; + OnPropertyChanged(nameof(GooseIdFilterOptions)); } public string SelectedGooseIdFilter @@ -254,7 +260,11 @@ public string SelectedGooseIdFilter get => _selectedGooseIdFilter; set { - var next = string.IsNullOrWhiteSpace(value) ? "All" : value; + // WPF can write null while replacing a ComboBox ItemsSource. + // Clear is the explicit "All" option, never a transient null. + if (string.IsNullOrWhiteSpace(value)) + return; + var next = value; if (string.Equals(_selectedGooseIdFilter, next, StringComparison.Ordinal)) return; @@ -286,16 +296,17 @@ public string GooseFilterSummaryText } public IReadOnlyList SelectedGooseDatasetValues => - BuildGooseDatasetValues(SelectedGooseMessage); + BuildGooseDatasetValues(GooseInspectorMessage); public string SelectedGooseSemanticText { get { - if (SelectedGooseMessage is null) - return "Select a GOOSE publisher to inspect semantic DataSet values."; + var inspected = GooseInspectorMessage; + if (inspected is null) + return "Select a GOOSE publisher or timeline event to inspect typed DataSet values."; - var match = ResolveSclGooseStream(SelectedGooseMessage); + var match = ResolveSclGooseStream(inspected); if (match is null) return HasSclProject ? "Semantic source: generic typed decode; no matching SCL GOOSE DataSet found." @@ -817,46 +828,98 @@ public SvStreamItem? SelectedStream } } + // Stable ListView containers: snapshot messages never own WPF selection. public IReadOnlyList GooseMessages => _gooseMessages; + public ObservableCollection GoosePublisherRows => _gooseCatalog.Rows; - public GooseMessageItem? SelectedGooseMessage + public GoosePublisherRow? SelectedGoosePublisherRow { - get => _selectedGooseMessage; + get => _gooseCatalog.Find(_selectedGoosePublisherKey); set { - if (ReferenceEquals(_selectedGooseMessage, value)) + // Ignore WPF's transient null during view/container recycling. + // ClearAsync is the explicit reset path. + if (value is null) + return; + if (value.Key == _selectedGoosePublisherKey && !_gooseInspectHistoricalEvent) return; + _selectedGoosePublisherKey = value.Key; + _selectedGooseMessage = value.Current; + _gooseInspectHistoricalEvent = false; BeginWorkspaceTargetTransition(); - _selectedGooseMessage = value; OnPropertyChanged(); - OnPropertyChanged(nameof(SelectedGooseDatasetValues)); - OnPropertyChanged(nameof(SelectedGooseSemanticText)); + RaiseGooseInspectorProperties(); RaiseDiagnosticScopeProperties(); RaiseAdvancedProperties(); } } + public GooseMessageItem? SelectedGooseMessage + { + get => _selectedGooseMessage; + set + { + if (value is null) + return; + var row = _gooseCatalog.Find(GoosePublisherKey.From(value)); + if (row is not null) + SelectedGoosePublisherRow = row; + } + } + + // Independent user intent: selecting a timeline event pins its historical + // coherent frame; clicking a publisher returns the inspector to live mode. public GooseTrafficRow? SelectedGooseTrafficRow { get => _selectedGooseTrafficRow; set { - if (ReferenceEquals(_selectedGooseTrafficRow, value)) + if (value is null || ReferenceEquals(_selectedGooseTrafficRow, value)) return; - - BeginWorkspaceTargetTransition(); _selectedGooseTrafficRow = value; - _selectedGooseMessage = value?.Source; - + _gooseInspectHistoricalEvent = true; + BeginWorkspaceTargetTransition(); OnPropertyChanged(); - OnPropertyChanged(nameof(SelectedGooseMessage)); - OnPropertyChanged(nameof(SelectedGooseDatasetValues)); - OnPropertyChanged(nameof(SelectedGooseSemanticText)); + RaiseGooseInspectorProperties(); RaiseAdvancedProperties(); } } + public GooseMessageItem? GooseInspectorMessage => + _gooseInspectHistoricalEvent ? _selectedGooseTrafficRow?.Source : _selectedGooseMessage; + + public string GooseInspectorDatasetCountText => + (GooseInspectorMessage?.DataValues.Count ?? 0).ToString(); + + public string GooseInspectorModeText + { + get + { + var item = GooseInspectorMessage; + if (item is null) + return "Select a publisher for live values or an event for historical evidence."; + if (_gooseInspectHistoricalEvent) + return $"Pinned event · {item.LastSeenUtc.ToLocalTime():HH:mm:ss.fff} · historical values"; + var ttl = Math.Max(1000.0, item.TimeAllowedToLiveMilliseconds); + var stale = SelectedGoosePublisherRow?.IsStale == true || + DateTime.UtcNow - item.LastSeenUtc > TimeSpan.FromMilliseconds(ttl); + return stale + ? $"STALE · last received {item.LastSeenUtc.ToLocalTime():HH:mm:ss.fff} · last valid values retained" + : "Live publisher · latest received typed DataSet values"; + } + } + + private void RaiseGooseInspectorProperties() + { + OnPropertyChanged(nameof(SelectedGooseMessage)); + OnPropertyChanged(nameof(GooseInspectorMessage)); + OnPropertyChanged(nameof(GooseInspectorDatasetCountText)); + OnPropertyChanged(nameof(GooseInspectorModeText)); + OnPropertyChanged(nameof(SelectedGooseDatasetValues)); + OnPropertyChanged(nameof(SelectedGooseSemanticText)); + } + public GooseMonitorSnapshot GooseSnapshot => _gooseSnapshot; public ProtocolMonitorSnapshot ProtocolMonitor => _state.ProtocolMonitor; public ObservableCollection PtpEvents => _state.PtpEvents; @@ -895,8 +958,6 @@ public int CurrentWorkspaceTabIndex if (IsGooseTabActive) { FlushBufferedGooseRows(); - if (SelectedGooseTrafficRow is null && _gooseHistory.Count > 0) - SelectedGooseTrafficRow = _gooseHistory[^1]; } if (IsDebugTabActive) UpdateDebugDisplaySnapshot(); @@ -2086,15 +2147,13 @@ private async Task RefreshAsync() _gooseSnapshot = gooseSnapshot; _gooseMessages = _gooseSnapshot.Messages; - if (_selectedGooseMessage is not null) - { - _selectedGooseMessage = _gooseMessages.FirstOrDefault(x => string.Equals(x.MessageId, _selectedGooseMessage.MessageId, StringComparison.OrdinalIgnoreCase)) - ?? _selectedGooseMessage; - } + _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey); + _selectedGooseMessage = SelectedGoosePublisherRow?.Current; + UpdateGooseIdFilterOptions(); foreach (var msg in _gooseSnapshot.Messages) { - var key = $"{msg.GoId}|{msg.GoCbRef}|{msg.DataSet}"; + var key = GoosePublisherKey.From(msg); if (!_lastGooseState.TryGetValue(key, out var last)) { @@ -2166,11 +2225,6 @@ private async Task RefreshAsync() SynchronizeSelectedStreamRow(); var selectedStreamId = _selectedStream?.StreamId; - if (IsGooseTabActive && SelectedGooseTrafficRow is null && _gooseHistory.Count > 0) - { - SelectedGooseTrafficRow = _gooseHistory[^1]; - } - if (IsAnalyzerTabActive) { // ARSVIN-style contract: render the actual runtime stream snapshot that the @@ -2755,18 +2809,15 @@ private void RaiseDiagnosticsProperties() private void RaiseGooseProperties() { OnPropertyChanged(nameof(GooseSnapshot)); - OnPropertyChanged(nameof(GooseMessages)); OnPropertyChanged(nameof(GooseStatusText)); OnPropertyChanged(nameof(GooseTotalMessagesText)); OnPropertyChanged(nameof(GooseDetectedCountText)); OnPropertyChanged(nameof(GoosePublisherCountText)); - OnPropertyChanged(nameof(GooseIdFilterOptions)); OnPropertyChanged(nameof(GooseFilterSummaryText)); OnPropertyChanged(nameof(GooseEmptyTitle)); OnPropertyChanged(nameof(GooseEmptyGuidance)); - OnPropertyChanged(nameof(SelectedGooseDatasetValues)); - OnPropertyChanged(nameof(SelectedGooseSemanticText)); - _gooseHistoryView.Refresh(); + if (!_gooseInspectHistoricalEvent && _selectedGoosePublisherKey.HasValue) + RaiseGooseInspectorProperties(); } private void RaiseTimingProperties() @@ -4262,8 +4313,12 @@ private async Task StopAsync() s.StatusSoftBrush = "#1C2A38"; } + // Stop keeps the last selected publisher as STALE; Clear removes it. _gooseMessages = Array.Empty(); + _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey); _gooseSnapshot = new GooseMonitorSnapshot(); + UpdateGooseIdFilterOptions(); + RaiseGooseInspectorProperties(); OnPropertyChanged(nameof(Streams)); OnPropertyChanged(nameof(SelectedStream)); @@ -4281,6 +4336,10 @@ private async Task ClearAsync() _state.ClearRuntimeData(); _gooseSnapshot = new GooseMonitorSnapshot { StatusText = "Cleared" }; _gooseMessages = Array.Empty(); + _gooseCatalog.Clear(); + _selectedGoosePublisherKey = null; + _gooseInspectHistoricalEvent = false; + UpdateGooseIdFilterOptions(); _gooseHistory.Clear(); _lastGooseState.Clear(); _pendingGooseRows.Clear(); @@ -4307,10 +4366,9 @@ private async Task ClearAsync() _frequencyStable = true; UpdateDebugDisplaySnapshot(); RaiseAll(DateTime.UtcNow, force: true); - OnPropertyChanged(nameof(SelectedGooseMessage)); + OnPropertyChanged(nameof(SelectedGoosePublisherRow)); OnPropertyChanged(nameof(SelectedGooseTrafficRow)); - OnPropertyChanged(nameof(SelectedGooseDatasetValues)); - OnPropertyChanged(nameof(SelectedGooseSemanticText)); + RaiseGooseInspectorProperties(); } private void UpdateRefreshCadence() @@ -4627,7 +4685,8 @@ private void FlushBufferedGooseRows(bool flushAll = false) _gooseHistory.RemoveAt(0); } - _gooseHistoryView.Refresh(); + if (flushed > 0) + _gooseHistoryView.Refresh(); } private void TryFlushBufferedGooseRows() From 508fbdde935728ba0f69e9b0ce95d0dada2e9dd3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:25:13 +0700 Subject: [PATCH 04/21] fix(goose): bind stable publisher rows and inspector to one live or historical snapshot --- src/ProcessBus.App.Wpf/MainWindow.xaml | 46 +++++++++++++------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 98612882..e61c7b1d 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -1407,8 +1407,8 @@ FontWeight="SemiBold" Margin="0,0,0,8"/> - @@ -3176,18 +3176,18 @@ Foreground="#F4FAFF" FontSize="16" FontWeight="SemiBold"/> - -