Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
35 changes: 29 additions & 6 deletions src/core/docs/AxoIncidentBar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand All @@ -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 |
Expand Down
18 changes: 18 additions & 0 deletions src/core/docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
@using AXSharp.Connector
@using AXSharp.Presentation.Blazor.Controls.RenderableContent
@using Microsoft.AspNetCore.Components.Authorization
@using Serilog
@inherits RenderableComplexComponentBase<ITwinObject>
@implements IAsyncDisposable

Expand All @@ -24,8 +25,9 @@
<span class="@AxoIncidentBarPresenter.BadgeClass(sev)">@SeverityName(sev)</span>
@if (_state.TopCause is { } top)
{
<span class="text-base font-semibold">@top.Message.SenderDisplayName</span>
<span class="text-base font-semibold" title="@top.Message.SenderSymbol">@top.Message.SenderDisplayName</span>
<span class="text-text/80 truncate">@top.Message.DisplayMessage</span>
<span class="text-xs text-text/60 font-mono truncate">@top.Message.SenderSymbol</span>
}
</div>
<div class="flex flex-wrap items-center gap-2">
Expand Down Expand Up @@ -57,7 +59,8 @@
<div class="flex flex-wrap items-center justify-between gap-2 p-1 @(rowMuted ? "opacity-50" : null)">
<div class="flex flex-wrap items-center gap-2">
<span class="@AxoIncidentBarPresenter.BadgeClass(rowBucket)">@SeverityName(rowBucket)</span>
<span class="font-semibold">@row.Cause.Message.SenderDisplayName</span>
<span class="font-semibold" title="@row.Cause.Message.SenderSymbol">@row.Cause.Message.SenderDisplayName</span>
<span class="text-xs text-text/60 font-mono truncate">@row.Cause.Message.SenderSymbol</span>
<span class="text-text/80 truncate">@row.Cause.Message.DisplayMessage</span>
</div>
<div class="flex flex-wrap items-center gap-1 text-xs text-text/60">
Expand Down Expand Up @@ -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.");
}
Comment on lines +117 to +120
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);
}
Expand All @@ -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 ?? "<none>");
}
catch (Exception ex)
{
Log.Warning(ex, "AxoIncidentBarView.Tick: read/recompute failed.");
}
catch { /* swallow: a bad cycle should not crash the bar */ }
finally
{
ScheduleNextTick();
Expand Down
Loading
Loading