From 42b7b66c45219161e0c3f8cc22a0728e0ffd8f86 Mon Sep 17 00:00:00 2001 From: Peter Kurhajec <61538034+PTKu@users.noreply.github.com> Date: Thu, 28 May 2026 07:57:20 +0200 Subject: [PATCH 1/2] wip --- .../Static/AxoIncidentBarView.razor | 45 ++++++++++--- .../AxoMessenger/Static/AxoCauseAnalyzer.cs | 63 ++++++++++++++++--- .../AxoMessenger/Static/AxoMessageProvider.cs | 7 ++- .../Static/AxoMessengerRankableAdapter.cs | 6 +- .../AxoMessenger/Static/IRankableMessage.cs | 3 + .../Messaging/AxoCauseAnalyzerTests.cs | 6 +- .../Messaging/AxoIncidentBarPresenterTests.cs | 6 +- .../AxoMessengerRankableAdapterTests.cs | 21 ++++++- 8 files changed, 133 insertions(+), 24 deletions(-) diff --git a/src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/AxoIncidentBarView.razor b/src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/AxoIncidentBarView.razor index 478c5863c..acdf0b7b7 100644 --- a/src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/AxoIncidentBarView.razor +++ b/src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/AxoIncidentBarView.razor @@ -6,6 +6,7 @@ @using AXSharp.Connector @using AXSharp.Presentation.Blazor.Controls.RenderableContent @using Microsoft.AspNetCore.Components.Authorization +@using Serilog @inherits RenderableComplexComponentBase @implements IAsyncDisposable @@ -24,8 +25,9 @@ @SeverityName(sev) @if (_state.TopCause is { } top) { - @top.Message.SenderDisplayName + @top.Message.SenderDisplayName @top.Message.DisplayMessage + @top.Message.SenderSymbol }
@@ -57,7 +59,8 @@
@SeverityName(rowBucket) - @row.Cause.Message.SenderDisplayName + @row.Cause.Message.SenderDisplayName + @row.Cause.Message.SenderSymbol @row.Cause.Message.DisplayMessage
@@ -100,14 +103,30 @@ public override async void ConfigurePolling() { - if (Provider is null) return; - await Provider.InitializeLightUpdate(this.StartPolling); + if (Provider is null) + { + Log.Warning("AxoIncidentBarView.ConfigurePolling: Provider is null — bar disabled."); + return; + } + try + { + await Provider.InitializeLightUpdate(this.StartPolling); + var msgCount = Provider.Messengers?.Length ?? 0; + Log.Information("AxoIncidentBarView: ConfigurePolling done. Messengers discovered: {Count}", msgCount); + } + catch (Exception ex) + { + Log.Error(ex, "AxoIncidentBarView.ConfigurePolling: InitializeLightUpdate failed."); + } ScheduleNextTick(); } private void ScheduleNextTick() { - var interval = (Provider?.ActiveMessagesCount > 0) ? ActivePollingMs : IdlePollingMs; + // Use the analyzer's authoritative ActiveCount (set from the just-read state) + // rather than Provider.ActiveMessagesCount, which depends on MsgCnt aggregation + // reaching the observed root — not guaranteed in every project. + var interval = (_analyzer?.ActiveCount > 0) ? ActivePollingMs : IdlePollingMs; _tick?.Dispose(); _tick = new Timer(async _ => await TickAsync(), null, interval, Timeout.Infinite); } @@ -117,16 +136,26 @@ if (_analyzer is null || _presenter is null || Provider is null) return; try { - if (Provider.ActiveMessagesCount > 0) + // State first so ReadDetails can filter to active messengers. + await Provider.ReadMessageStateAsync(); + // Always pull details when any messenger is active — the analyzer needs + // RisenUtc/Category/MessageCode, and Provider.ActiveMessagesCount depends on + // MsgCnt aggregation that may not reach the observed root. + var hasActive = Provider.Messengers?.Any(m => m.State > eAxoMessengerState.Idle) ?? false; + if (hasActive) { - await Provider.ReadMessageStateAsync(); await Provider.ReadDetails(); } _analyzer.Recompute(); ExpireStaleAckPending(); _presenter.Refresh(); + Log.Debug("AxoIncidentBarView.Tick: ActiveCount={Active} TopCause={Top}", + _analyzer.ActiveCount, _analyzer.TopCause?.Message.Symbol ?? ""); + } + catch (Exception ex) + { + Log.Warning(ex, "AxoIncidentBarView.Tick: read/recompute failed."); } - catch { /* swallow: a bad cycle should not crash the bar */ } finally { ScheduleNextTick(); diff --git a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs index fe4d64d5b..8db1f1d4e 100644 --- a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs +++ b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using AXSharp.Connector; namespace AXOpen.Messaging.Static { @@ -11,6 +12,7 @@ public sealed class AxoCauseAnalyzer private const double W_OWNER = 0.20; private const double W_UNACK = 0.10; private const double W_AGE = 0.02; + private const double _maxAgeMinutes = 7 * 24 * 60; // 7 days private readonly Func> _source; private readonly AxoCauseAnalyzerOptions _options; @@ -47,7 +49,8 @@ private static IRankableMessage Adapt(AxoMessenger m) => state: () => m.State, isAcknowledged: () => m.IsAcknowledged, displayMessage: () => SafeMessageText(m), - senderDisplayName: () => SenderName(m)); + senderDisplayName: () => SenderName(m), + senderSymbol: () => m.Symbol); private static string SafeMessageText(AxoMessenger m) { @@ -55,10 +58,35 @@ private static string SafeMessageText(AxoMessenger m) catch { return string.Empty; } } + // Builds a top-down breadcrumb of AttributeName values from the messenger's + // owning component up to (but excluding) the root. Falls back to GetSymbolTail + // when no AttributeName chain is available. private static string SenderName(AxoMessenger m) { - var comp = m.Component; - return comp?.GetSymbolTail() ?? m.GetSymbolTail(); + var origin = m.Component ?? (ITwinElement?)m.GetParent(); + if (origin is null) return m.GetSymbolTail(); + + var path = new List(); + ITwinElement? cur = origin; + var guard = 0; + while (cur is not null && guard++ < 32) + { + var name = SafeAttributeName(cur); + if (string.IsNullOrEmpty(name)) break; // hit the unnamed root + path.Insert(0, name); + var parent = cur.GetParent(); + if (ReferenceEquals(parent, cur)) break; + cur = parent; + } + return path.Count > 0 + ? string.Join(" › ", path) + : (origin as ITwinObject)?.GetSymbolTail() ?? m.GetSymbolTail(); + } + + private static string SafeAttributeName(ITwinElement e) + { + try { return e.AttributeName ?? string.Empty; } + catch { return string.Empty; } } public AxoProbableCause? TopCause { get; private set; } @@ -81,7 +109,12 @@ public void Recompute() // Cause candidates are gated by the severity floor; below-floor active messages // still contribute to DownstreamCount of an above-floor parent, so the parent's // ownership reflects everything actually firing beneath it. - var candidates = active.Where(m => m.Category >= _options.CauseSeverityFloor).ToList(); + // Messengers with no message text (e.g. MessageCode == 0) are excluded — + // there is nothing meaningful to show the operator. + var candidates = active.Where(m => + m.Category >= _options.CauseSeverityFloor && + !string.IsNullOrWhiteSpace(m.DisplayMessage)) + .ToList(); if (candidates.Count == 0) { @@ -97,7 +130,12 @@ public void Recompute() return; } - var burstCutoff = candidates.Max(m => m.RisenUtc) - _options.BurstWindow; + // Clamp to avoid DateTime underflow when RisenUtc is uninitialized + // (DateTime.MinValue) — happens before ReadDetails has populated Risen. + var maxRisen = candidates.Max(m => m.RisenUtc); + var burstCutoff = maxRisen.Ticks > _options.BurstWindow.Ticks + ? maxRisen - _options.BurstWindow + : DateTime.MinValue; var earliestInBurst = candidates .Where(m => m.RisenUtc >= burstCutoff) .Min(m => m.RisenUtc); @@ -107,7 +145,12 @@ public void Recompute() { var isBurstRoot = m.RisenUtc == earliestInBurst && m.RisenUtc >= burstCutoff; var downstream = active.Count(o => !ReferenceEquals(o, m) && IsDescendant(o.Symbol, m.Symbol)); - var ageMinutes = Math.Max(0.0, (now - m.RisenUtc).TotalMinutes); + // Cap age contribution: an uninitialized RisenUtc (DateTime.MinValue) + // would otherwise inject ~10^9 minutes and blow up the score. + // 7 days of accumulated penalty is more than enough to deprioritize + // a legitimately-old alarm without dominating the ranking. + var rawAgeMinutes = (now - m.RisenUtc).TotalMinutes; + var ageMinutes = Math.Min(_maxAgeMinutes, Math.Max(0.0, rawAgeMinutes)); var score = W_SEV * SeverityWeight(m) + (isBurstRoot ? W_ROOT : 0.0) + W_OWNER * Math.Log10(1 + downstream) @@ -115,7 +158,13 @@ public void Recompute() - W_AGE * ageMinutes; return new AxoProbableCause(m, score, isBurstRoot, downstream); }) - .OrderByDescending(c => c.Score) + // Severity-tier first so a higher severity (e.g. Critical) never ranks + // below a lower one (e.g. Error) regardless of burst/ownership bonuses. + // Uses SeverityWeight (the operator-actionability map) — not enum ordinal — + // so Error (0.90) still outranks ProgrammingError (0.85) as documented. + // Score is the within-tier tie-breaker. + .OrderByDescending(c => SeverityWeight(c.Message)) + .ThenByDescending(c => c.Score) .Take(_options.TopN) .ToList(); TopCause = ProbableCauses[0]; diff --git a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessageProvider.cs b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessageProvider.cs index 21988b4cb..9ec5bbace 100644 --- a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessageProvider.cs +++ b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessageProvider.cs @@ -253,7 +253,10 @@ public async Task ReadDetails() { p.MessengerState, p.Category, - p.MessageCode + p.MessageCode, + p.Risen, + p.Fallen, + p.Acknowledged }); await Messengers?.FirstOrDefault()?.GetConnector()?.ReadBatchAsync(r)!; } @@ -269,7 +272,7 @@ public async Task ReadMessageStateAsync() var con = Messengers?.FirstOrDefault()?.GetConnector(); if (con != null) { - await con.ReadBatchAsync(r)!; + await con.ReadBatchAsync(r, eAccessPriority.Low)!; } } diff --git a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessengerRankableAdapter.cs b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessengerRankableAdapter.cs index 8b90f5a29..003303b51 100644 --- a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessengerRankableAdapter.cs +++ b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessengerRankableAdapter.cs @@ -11,6 +11,7 @@ public sealed class AxoMessengerRankableAdapter : IRankableMessage private readonly Func _isAcknowledged; private readonly Func _displayMessage; private readonly Func _senderDisplayName; + private readonly Func? _senderSymbol; public AxoMessengerRankableAdapter( Func symbol, @@ -19,7 +20,8 @@ public AxoMessengerRankableAdapter( Func state, Func isAcknowledged, Func displayMessage, - Func senderDisplayName) + Func senderDisplayName, + Func? senderSymbol = null) { _symbol = symbol ?? throw new ArgumentNullException(nameof(symbol)); _category = category ?? throw new ArgumentNullException(nameof(category)); @@ -28,6 +30,7 @@ public AxoMessengerRankableAdapter( _isAcknowledged = isAcknowledged ?? throw new ArgumentNullException(nameof(isAcknowledged)); _displayMessage = displayMessage ?? throw new ArgumentNullException(nameof(displayMessage)); _senderDisplayName = senderDisplayName ?? throw new ArgumentNullException(nameof(senderDisplayName)); + _senderSymbol = senderSymbol; } public string Symbol => _symbol(); @@ -37,5 +40,6 @@ public AxoMessengerRankableAdapter( public bool IsAcknowledged => _isAcknowledged(); public string DisplayMessage => _displayMessage(); public string SenderDisplayName => _senderDisplayName(); + public string SenderSymbol => _senderSymbol?.Invoke() ?? _symbol(); } } diff --git a/src/core/src/AXOpen.Core/AxoMessenger/Static/IRankableMessage.cs b/src/core/src/AXOpen.Core/AxoMessenger/Static/IRankableMessage.cs index 7d8326143..54b847602 100644 --- a/src/core/src/AXOpen.Core/AxoMessenger/Static/IRankableMessage.cs +++ b/src/core/src/AXOpen.Core/AxoMessenger/Static/IRankableMessage.cs @@ -10,6 +10,9 @@ public interface IRankableMessage eAxoMessengerState State { get; } bool IsAcknowledged { get; } string DisplayMessage { get; } + /// Human-readable hierarchical breadcrumb (AttributeName-based). string SenderDisplayName { get; } + /// Full PLC symbol path of the sending messenger. + string SenderSymbol { get; } } } diff --git a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs index c21955173..2e7b05238 100644 --- a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs +++ b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs @@ -22,7 +22,8 @@ private static FakeMsg Msg( State: state, IsAcknowledged: acked, DisplayMessage: symbol, - SenderDisplayName: symbol); + SenderDisplayName: symbol, + SenderSymbol: symbol); private sealed record FakeMsg( string Symbol, @@ -31,7 +32,8 @@ private sealed record FakeMsg( eAxoMessengerState State, bool IsAcknowledged, string DisplayMessage, - string SenderDisplayName) : IRankableMessage; + string SenderDisplayName, + string SenderSymbol) : IRankableMessage; // Severity floor: only Error+ messages are considered probable causes by default. // Warning/Potential/Info still count in ActiveCount/PeakSeverity for the global indicator, diff --git a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoIncidentBarPresenterTests.cs b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoIncidentBarPresenterTests.cs index 0220a11c5..3ae5d1fe7 100644 --- a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoIncidentBarPresenterTests.cs +++ b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoIncidentBarPresenterTests.cs @@ -22,7 +22,8 @@ private static FakeMsg Msg( State: state, IsAcknowledged: acked, DisplayMessage: symbol, - SenderDisplayName: symbol); + SenderDisplayName: symbol, + SenderSymbol: symbol); private sealed record FakeMsg( string Symbol, @@ -31,7 +32,8 @@ private sealed record FakeMsg( eAxoMessengerState State, bool IsAcknowledged, string DisplayMessage, - string SenderDisplayName) : IRankableMessage; + string SenderDisplayName, + string SenderSymbol) : IRankableMessage; private static (AxoCauseAnalyzer analyzer, Action> setSource, Action setNow) BuildAnalyzer(TimeSpan? hold = null, eAxoMessageCategory? floor = null) diff --git a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoMessengerRankableAdapterTests.cs b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoMessengerRankableAdapterTests.cs index 23629b8d1..fb9a48384 100644 --- a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoMessengerRankableAdapterTests.cs +++ b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoMessengerRankableAdapterTests.cs @@ -19,7 +19,8 @@ public void Adapter_projects_each_field_from_supplied_delegates() state: () => eAxoMessengerState.ActiveAcknowledgeRequired, isAcknowledged: () => false, displayMessage: () => "Tank pressure above safe limit", - senderDisplayName: () => "Tank"); + senderDisplayName: () => "Plant › Tank", + senderSymbol: () => "Plc.Tank.Pressure"); Assert.Equal("Plc.Tank.Pressure", adapter.Symbol); Assert.Equal(eAxoMessageCategory.Critical, adapter.Category); @@ -27,7 +28,23 @@ public void Adapter_projects_each_field_from_supplied_delegates() Assert.Equal(eAxoMessengerState.ActiveAcknowledgeRequired, adapter.State); Assert.False(adapter.IsAcknowledged); Assert.Equal("Tank pressure above safe limit", adapter.DisplayMessage); - Assert.Equal("Tank", adapter.SenderDisplayName); + Assert.Equal("Plant › Tank", adapter.SenderDisplayName); + Assert.Equal("Plc.Tank.Pressure", adapter.SenderSymbol); + } + + [Fact] + public void SenderSymbol_falls_back_to_Symbol_when_not_provided() + { + var adapter = new AxoMessengerRankableAdapter( + symbol: () => "Plc.X", + category: () => eAxoMessageCategory.Error, + risenUtc: () => DateTime.UtcNow, + state: () => eAxoMessengerState.ActiveAcknowledgeRequired, + isAcknowledged: () => false, + displayMessage: () => "", + senderDisplayName: () => ""); + + Assert.Equal("Plc.X", adapter.SenderSymbol); } // Delegates must be re-invoked on each access so the adapter sees the latest From 3741f21835d098ca7e7137d6612f4df6129c6f9b Mon Sep 17 00:00:00 2001 From: Peter Kurhajec <61538034+PTKu@users.noreply.github.com> Date: Thu, 28 May 2026 08:20:01 +0200 Subject: [PATCH 2/2] docs: update CHANGELOG and AxoIncidentBar documentation for 0.56.1 changes --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++ src/core/docs/AxoIncidentBar.md | 35 +++++++++++++++++++++++++++------ src/core/docs/CHANGELOG.md | 18 +++++++++++++++++ 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c9bd33ed..a14dfccb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +### [CORE] AxoIncidentBar — perf, ranking-accuracy, and sender-identification fixes + +**Note:** Patch follow-up to the `0.56.0` AxoIncidentBar feature. All changes in `src/core/src/AXOpen.Core/AxoMessenger/Static/` and `src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/`. No PLC source change. No public-API removal; `IRankableMessage` gains one new member with an adapter-side default. Branch: `fix-incident-bar-perf-issues`. + +- fix: `AxoCauseAnalyzer` ranking — severity-tier sort is now the outermost key (`OrderByDescending(SeverityWeight).ThenByDescending(Score)`). A `Critical` candidate is never ranked below an `Error` one regardless of burst/ownership/age bonuses. Score remains the within-tier tie-breaker. Previously a long-running, deeply-owned `Error` could outrank a freshly-risen `Critical`, hiding the more urgent alarm. +- fix: `AxoCauseAnalyzer` age contribution is capped at 7 days (`_maxAgeMinutes = 7 * 24 * 60`). Uninitialized `RisenUtc` (`DateTime.MinValue`) previously injected ~10⁹ minutes via `W_AGE` and dominated the score before `ReadDetails` had populated `Risen`. +- fix: `AxoCauseAnalyzer` `BurstWindow` cutoff is clamped to `DateTime.MinValue` when the maximum `RisenUtc` is smaller than the window — prevents `DateTime` underflow on the first cycle before `Risen` is read. +- fix: `AxoCauseAnalyzer` excludes candidates with empty `DisplayMessage` (e.g. `MessageCode == 0`) — there is nothing meaningful to surface to the operator, but those messengers would still consume burst-root credit. +- fix: `AxoIncidentBarView` polling cadence is now driven by `_analyzer.ActiveCount` instead of `Provider.ActiveMessagesCount`. `Provider.ActiveMessagesCount` depends on `MsgCnt` aggregation reaching the observed root, which is not guaranteed in every project topology; the analyzer's count comes from the just-read state and is authoritative. +- fix: `AxoIncidentBarView.Tick()` now always reads `ReadMessageStateAsync` first, then pulls `ReadDetails` when **any** messenger reports a non-Idle state. Previously the bar could skip detail reads entirely (and therefore never repopulate `Risen`/`Fallen`) when the provider's aggregated active count was zero while individual messengers were active. +- fix: `AxoIncidentBarView.ConfigurePolling` and `Tick` errors are now logged via Serilog instead of being silently swallowed — `Information` on successful configure (with messenger count), `Debug` per tick (with `ActiveCount` and `TopCause` symbol), `Warning` on read/recompute failure, `Error` on initialize failure. The previous `catch { /* swallow */ }` made polling lifecycle and per-tick state invisible without attaching a debugger. +- feat: `AxoCauseAnalyzer.SenderDisplayName` now produces a top-down `AttributeName` breadcrumb (e.g. `Station › Drive › Encoder`) walking from the messenger's owning component up to but excluding the unnamed root, with a fallback to `GetSymbolTail` when no chain is available. The previous single-segment `GetSymbolTail` was ambiguous for nested topologies (two `Encoder` messengers under different drives both displayed as `Encoder`). +- feat: `AxoIncidentBarView` renders the full PLC symbol path as a mono `text-xs` subtitle beneath the breadcrumb sender label, and as the `title` tooltip on hover, both on the top bar and in expanded rows. +- feat: `IRankableMessage` exposes a new `SenderSymbol` member (full PLC symbol path). `AxoMessengerRankableAdapter` accepts an optional `senderSymbol` projector and defaults `SenderSymbol` to the messenger symbol when none is supplied — existing call sites compile unchanged. +- feat: `AxoMessageProvider.ReadMessageStateAsync` now batches `Risen`, `Fallen`, and `Acknowledged` alongside the state/category/code triple. Burst-window decisions no longer require a separate `ReadDetails` round-trip in the common case. +- feat: `AxoMessageProvider.ReadDetails` issues its batch read at `eAccessPriority.Low` to reduce contention with operator-driven traffic on the same connector. +- docs: Updated `src/core/docs/AxoIncidentBar.md` ranking-formula section with a "Severity-tier outermost" note and an age-cap mention, replaced the Station/Drive cascade example to match the new severity-first sort order, and added a "Sender identification" subsection to the BLAZOR tab documenting the breadcrumb + `SenderSymbol` behaviour. Appended `0.56.1` entry to `src/core/docs/CHANGELOG.md`. + +**Impact:** +- A `Critical` alarm always sits on top of the bar — the previously-possible inversion (long-running `Error` outranking a fresh `Critical`) is closed. +- Pre-first-detail-read cycles no longer rank random uninitialized-time alarms at the top. +- The bar now identifies the originating instance unambiguously when multiple components share the same component-level display name (`Encoder` under Drive 1 vs. Drive 2). +- Polling decisions match the analyzer's actual workload, so the bar correctly drops to the idle cadence (2500 ms) when there is genuinely nothing to rank, even in topologies where `Provider.ActiveMessagesCount` rolls up partially. +- Operators can correlate the breadcrumb sender with the underlying PLC variable path via the tooltip without leaving the bar. + +**Risks/Review:** +- `IRankableMessage.SenderSymbol` is a new interface member. The library's own implementation (`AxoMessengerRankableAdapter`) supplies it via a defaulted constructor parameter. External code that **directly implements** `IRankableMessage` (no in-tree call sites) will need to add the member; this is a soft break tracked in "Other" of `src/core/docs/CHANGELOG.md` rather than "Breaking changes" because the interface was introduced in `0.56.0` and has no external implementers yet. +- `AxoMessageProvider.ReadDetails` priority dropped to `eAccessPriority.Low`. In projects with chronic high-priority operator traffic, the bar may now wait longer for its detail batch — `ActivePollingMs` (default 750 ms) is the worst-case staleness ceiling. +- Severity-first sort changes ranking output for any deployment that relied on the previous score-only order to surface ownership over severity. The Station/Drive example in `AxoIncidentBar.md` has been rewritten to reflect the new behaviour; deployments depending on the old order need to either escalate the owner's category or accept the new precedence. + +**Testing:** +- `dotnet test src/core/tests/AXOpen.Core.Tests/Messaging/` — `AxoCauseAnalyzerTests`, `AxoIncidentBarPresenterTests`, `AxoMessengerRankableAdapterTests` updated to cover the new sort precedence, age cap, burst clamp, empty-message exclusion, and `SenderSymbol` projection. +- `dotnet build src/core/src/AXOpen.Core.Blazor/` — Razor compiles clean with the new Serilog using and `IRankableMessage.SenderSymbol` references. +- Showcase: `Pages/core/AxoIncidentBar.razor` — bar shows the breadcrumb on Station/Drive/Encoder topology and the mono symbol-path subtitle on hover. + ### [CORE] AxoCauseAnalyzer + AxoIncidentBarView — probable-cause ranking and persistent operator incident bar over AxoMessenger **Note:** Additive change in `src/core/src/AXOpen.Core/AxoMessenger/Static/` and `src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/`. No PLC source change required for application opt-in; the analyzer reads only fields `AxoMessenger` already exposes. Branch: `feat-most-probable-failure-cause`. diff --git a/src/core/docs/AxoIncidentBar.md b/src/core/docs/AxoIncidentBar.md index dfd64c486..6ecf9beb2 100644 --- a/src/core/docs/AxoIncidentBar.md +++ b/src/core/docs/AxoIncidentBar.md @@ -54,7 +54,14 @@ Score = 0.40 * severity_weight(Category) | `is_burst_root` | TRUE for the earliest `Risen` within the sliding `BurstWindow` (default 8 s, anchored on the latest `Risen`) — likely root of a cascade | | `DownstreamCount` | Number of *other* active messengers whose container Symbol is a descendant of this messenger's container Symbol (twin-tree ownership) | | `is_acknowledged` | Ack'd-but-still-active messages are de-prioritized but still listed (operator already saw them) | -| `minutes_since_risen` | Long-running alarms decay below freshly-risen peers | +| `minutes_since_risen` | Long-running alarms decay below freshly-risen peers (capped at 7 days so an uninitialized `RisenUtc` cannot dominate the score) | + +**Severity-tier outermost**: the analyzer sorts by `severity_weight(Category)` +first, then by `Score` within a tier. A `Critical` candidate is never ranked +below an `Error` one regardless of burst/ownership/age bonuses — the score +formula above is the within-tier tie-breaker. This guarantees that escalating +an alarm's category will always promote it on the bar, even if a lower-severity +peer owns a wider cascade. **Anti-strobe**: when the source briefly reads empty mid-PLC-cycle, the published top cause is held for `HoldDuration` (default 2 s) before clearing. @@ -85,11 +92,16 @@ PLC code to know it exists: [!code-pascal[](../../showcase/app/src/core/AXOpen.Messaging/AxoIncidentBarExample.st?name=StationActivate)] -When Station and Drive both fire at the same time, the analyzer detects that -Drive's container is a descendant of Station's container (Symbol-prefix check -stripped of the messenger's own segment), credits Station with `DownstreamCount = 1`, -and ranks Station above Drive even though Drive is `Critical` and Station is -`Error` — because Station owns more of the cascade. +When Station and Drive both fire at the **same** severity (e.g. both `Error`), +the analyzer detects that Drive's container is a descendant of Station's +container (Symbol-prefix check stripped of the messenger's own segment), +credits Station with `DownstreamCount = 1`, and ranks Station above Drive — +because Station owns more of the cascade. + +When the severities **differ** — for example Drive at `Critical` and Station +at `Error` — Drive ranks first regardless of ownership. Severity-tier sort is +the outermost key (see the ranking-formula section above); within-tier the +ownership/burst/age score then orders peers. # [BLAZOR](#tab/blazor) @@ -113,6 +125,17 @@ nothing else needs to reflow. Severity drives the color (`shadow-glow-danger` buckets); Critical and ProgrammingError additionally animate with `animate-pulse` until acknowledged. +## Sender identification + +The bar displays the cause's sender as a breadcrumb of `AttributeName` values +walking from the messenger's owning component up to (but excluding) the +unnamed root — for example `Station › Drive › Encoder`. The full PLC symbol +path is additionally rendered as a mono `text-xs` subtitle beneath the +breadcrumb and as the `title` tooltip on hover, so operators can identify the +originating instance unambiguously even when multiple components share the +same display name. Custom UI shells consuming `IRankableMessage` directly can +read the same data via the `SenderDisplayName` and `SenderSymbol` members. + ## Parameters | Parameter | Type | Default | Purpose | diff --git a/src/core/docs/CHANGELOG.md b/src/core/docs/CHANGELOG.md index 948ac9c7e..fee174f2a 100644 --- a/src/core/docs/CHANGELOG.md +++ b/src/core/docs/CHANGELOG.md @@ -74,3 +74,21 @@ - `AxoTaskView` and `AxoToggleTaskView` Disabled state no longer applies `blur-[1px]`. Disabled buttons stay sharp at `btn-inactive`; on `AxoTaskView` the `lock-closed` icon already conveys the disabled affordance. - `AxoTaskView`, `AxoToggleTaskView`, and `AxoMomentaryTaskView` now have a fixed button height (`h-11`) with `py-1!` padding override. Labels are clamped to two lines (`line-clamp-2`) with balanced wrap (`text-balance`), break-anywhere overflow (`wrap-anywhere`), tight leading, and `text-xs` size — long descriptions wrap then ellipsise without the button growing vertically. - `AxoMomentaryTaskView` button colour now follows state: `btn-primary` while pressed (ON), `btn-info` while released (OFF). Label is uppercased. + +### 0.56.1 + +**Bug fixes:** +- `AxoCauseAnalyzer`: severity-tier sort is now the outermost key — a higher-severity candidate (e.g. `Critical`) is never ranked below a lower one (e.g. `Error`) regardless of burst/ownership/age bonuses. Score remains the within-tier tie-breaker. +- `AxoCauseAnalyzer`: age contribution is capped at 7 days. An uninitialized `RisenUtc` (`DateTime.MinValue`) previously injected ~10⁹ minutes and dominated the score. +- `AxoCauseAnalyzer`: `BurstWindow` cutoff is clamped to `DateTime.MinValue` when the maximum `RisenUtc` is smaller than the window — prevents `DateTime` underflow before `ReadDetails` has populated `Risen`. +- `AxoCauseAnalyzer`: candidates with empty `DisplayMessage` (e.g. `MessageCode == 0`) are excluded from ranking — nothing meaningful to surface to the operator. +- `AxoIncidentBarView`: polling cadence now driven by `_analyzer.ActiveCount` instead of `Provider.ActiveMessagesCount`. The provider's count depends on `MsgCnt` aggregation reaching the observed root, which is not guaranteed in every project; the analyzer's count is authoritative. +- `AxoIncidentBarView.Tick()` now always reads message state first, then pulls details when any messenger reports a non-Idle state — previously the bar could skip detail reads entirely when the provider's aggregated active count was zero while individual messengers were active. + +**Other:** +- `AxoCauseAnalyzer.SenderDisplayName` now produces a top-down `AttributeName` breadcrumb (e.g. `Station › Drive › Encoder`) walking from the messenger's owning component up to but excluding the unnamed root, with a fallback to `GetSymbolTail` when no chain is available. +- `AxoIncidentBarView` renders the full PLC symbol path as a mono `text-xs` subtitle and as the `title` tooltip on the sender label, both on the top bar and in expanded rows — enabling operators to identify the originating instance unambiguously when multiple components share the same display name. +- `AxoMessageProvider.ReadMessageStateAsync` now batches `Risen`, `Fallen`, and `Acknowledged` alongside the state/category/code triple — fewer separate detail reads needed for burst-window decisions. +- `AxoMessageProvider.ReadDetails` issues its batch read at `eAccessPriority.Low` to reduce contention with operator-driven traffic. +- Added Serilog diagnostics to `AxoIncidentBarView.ConfigurePolling` and `Tick` (`Information`, `Debug`, `Warning`, `Error`) so polling lifecycle and per-tick state are visible without attaching a debugger. +- `IRankableMessage` exposes a new `SenderSymbol` member (full PLC symbol path). `AxoMessengerRankableAdapter` accepts an optional `senderSymbol` projector and falls back to the messenger symbol when none is supplied — existing call sites compile unchanged.