From ed7eaa3dfe0f369f9417250bf8ac07c6f7d93db7 Mon Sep 17 00:00:00 2001 From: Peter Kurhajec <61538034+PTKu@users.noreply.github.com> Date: Tue, 26 May 2026 16:16:11 +0200 Subject: [PATCH 1/4] Add AxoCauseAnalyzer and AxoIncidentBarPresenter for incident analysis - Implemented AxoCauseAnalyzer to analyze and rank probable causes of incidents based on severity, acknowledgment status, and age. - Introduced AxoIncidentBarPresenter to manage the display state of incident bars, including visibility, severity, and acknowledgment status. - Created IRankableMessage interface and AxoMessengerRankableAdapter for message adaptation. - Added unit tests for AxoCauseAnalyzer, AxoIncidentBarPresenter, and AxoMessengerRankableAdapter to ensure functionality and correctness. - Defined AxoCauseAnalyzerOptions for configurable parameters like burst window and hold duration. - Established event handling for changes in top causes to trigger UI updates. --- .../Static/AxoIncidentBarView.razor | 222 ++++++++++++++ .../AxoMessenger/Static/AxoCauseAnalyzer.cs | 170 +++++++++++ .../Static/AxoIncidentBarPresenter.cs | 145 +++++++++ .../Static/AxoMessengerRankableAdapter.cs | 41 +++ .../AxoMessenger/Static/IRankableMessage.cs | 15 + .../Messaging/AxoCauseAnalyzerFactoryTests.cs | 29 ++ .../Messaging/AxoCauseAnalyzerTests.cs | 280 ++++++++++++++++++ .../Messaging/AxoIncidentBarPresenterTests.cs | 230 ++++++++++++++ .../AxoMessengerRankableAdapterTests.cs | 60 ++++ 9 files changed, 1192 insertions(+) create mode 100644 src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/AxoIncidentBarView.razor create mode 100644 src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs create mode 100644 src/core/src/AXOpen.Core/AxoMessenger/Static/AxoIncidentBarPresenter.cs create mode 100644 src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessengerRankableAdapter.cs create mode 100644 src/core/src/AXOpen.Core/AxoMessenger/Static/IRankableMessage.cs create mode 100644 src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerFactoryTests.cs create mode 100644 src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs create mode 100644 src/core/tests/AXOpen.Core.Tests/Messaging/AxoIncidentBarPresenterTests.cs create mode 100644 src/core/tests/AXOpen.Core.Tests/Messaging/AxoMessengerRankableAdapterTests.cs diff --git a/src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/AxoIncidentBarView.razor b/src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/AxoIncidentBarView.razor new file mode 100644 index 000000000..9fb943efc --- /dev/null +++ b/src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/AxoIncidentBarView.razor @@ -0,0 +1,222 @@ +@namespace AXOpen.Messaging.Static +@using System.Globalization +@using System.Threading +@using AXOpen.Core +@using AXOpen.Messaging +@using AXSharp.Connector +@using AXSharp.Presentation.Blazor.Controls.RenderableContent +@using Microsoft.AspNetCore.Components.Authorization +@inherits RenderableComplexComponentBase +@implements IAsyncDisposable + +@if (_state.IsVisible) +{ + var sev = _state.Severity; + var pulse = _state.Pulses ? "animate-pulse" : string.Empty; + + +} + +@code { + [Parameter] public AxoMessageProvider Provider { get; set; } = default!; + [Parameter] public AxoCauseAnalyzerOptions? Options { get; set; } + [Parameter] public string? PlcLabel { get; set; } + [Parameter] public bool AllowRestore { get; set; } + [Parameter] public string? Class { get; set; } + [Parameter] public int ActivePollingMs { get; set; } = 750; + [Parameter] public int IdlePollingMs { get; set; } = 2500; + [Parameter] public TimeSpan AckTimeout { get; set; } = TimeSpan.FromSeconds(2); + [CascadingParameter] public AuthenticationStateProvider? AuthState { get; set; } + + private AxoCauseAnalyzer? _analyzer; + private AxoIncidentBarPresenter? _presenter; + private Timer? _tick; + private AxoIncidentBarState _state = new(); + private bool _isExpanded; + private readonly Dictionary _ackPendingSince = new(); + + protected override void OnInitialized() + { + if (Provider is null) return; + _analyzer = AxoCauseAnalyzer.Create(Provider, Options); + _presenter = new AxoIncidentBarPresenter(_analyzer, Options?.IdleHysteresis); + _presenter.StateChanged += OnPresenterChanged; + } + + public override async void ConfigurePolling() + { + if (Provider is null) return; + await Provider.InitializeLightUpdate(this.StartPolling); + ScheduleNextTick(); + } + + private void ScheduleNextTick() + { + var interval = (Provider?.ActiveMessagesCount > 0) ? ActivePollingMs : IdlePollingMs; + _tick?.Dispose(); + _tick = new Timer(async _ => await TickAsync(), null, interval, Timeout.Infinite); + } + + private async Task TickAsync() + { + if (_analyzer is null || _presenter is null || Provider is null) return; + try + { + if (Provider.ActiveMessagesCount > 0) + { + await Provider.ReadMessageStateAsync(); + await Provider.ReadDetails(); + } + _analyzer.Recompute(); + ExpireStaleAckPending(); + _presenter.Refresh(); + } + catch { /* swallow: a bad cycle should not crash the bar */ } + finally + { + ScheduleNextTick(); + } + } + + private void ExpireStaleAckPending() + { + if (_ackPendingSince.Count == 0) return; + var now = DateTime.UtcNow; + var expired = _ackPendingSince.Where(kv => now - kv.Value > AckTimeout).Select(kv => kv.Key).ToList(); + foreach (var symbol in expired) + { + _ackPendingSince.Remove(symbol); + } + } + + private bool IsTopAckPending() => + _state.TopCause?.Message is { } m && _ackPendingSince.ContainsKey(m.Symbol); + + private void OnPresenterChanged() + { + _state = _presenter!.CurrentState; + InvokeAsync(StateHasChanged); + } + + private async Task AcknowledgeTopAsync() + { + if (_state.TopCause is null || Provider?.Messengers is null) return; + var symbol = _state.TopCause.Message.Symbol; + var live = Provider.Messengers.FirstOrDefault(m => m.Symbol == symbol); + if (live is null) return; + + _ackPendingSince[symbol] = DateTime.UtcNow; + _presenter!.NotifyAckPending(_state.TopCause.Message); + await InvokeAsync(StateHasChanged); + + try + { + var ident = AuthState is null ? null : (await AuthState.GetAuthenticationStateAsync())?.User?.Identity; + live.Acknowledge(ident!); + } + catch + { + _ackPendingSince.Remove(symbol); + _presenter.NotifyAckResolved(_state.TopCause.Message); + await InvokeAsync(StateHasChanged); + } + } + + private async Task RestoreTopAsync() + { + if (_state.TopCause is null || Provider?.Messengers is null) return; + var symbol = _state.TopCause.Message.Symbol; + var live = Provider.Messengers.FirstOrDefault(m => m.Symbol == symbol); + if (live is null) return; + var ident = AuthState is null ? null : (await AuthState.GetAuthenticationStateAsync())?.User?.Identity; + live.RestoreParentTask(ident); + } + + private void ToggleExpanded() + { + _isExpanded = !_isExpanded; + } + + private static string SeverityName(IncidentBarSeverity sev) => sev switch + { + IncidentBarSeverity.Danger => "Critical", + IncidentBarSeverity.Warning => "Warning", + IncidentBarSeverity.Info => "Info", + _ => string.Empty, + }; + + public async ValueTask DisposeAsync() + { + if (_presenter is not null) _presenter.StateChanged -= OnPresenterChanged; + if (_tick is not null) await _tick.DisposeAsync(); + } +} diff --git a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs new file mode 100644 index 000000000..d913fd41c --- /dev/null +++ b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AXOpen.Messaging.Static +{ + public sealed class AxoCauseAnalyzer + { + private const double W_SEV = 0.40; + private const double W_ROOT = 0.30; + private const double W_OWNER = 0.20; + private const double W_UNACK = 0.10; + private const double W_AGE = 0.02; + + private readonly Func> _source; + private readonly AxoCauseAnalyzerOptions _options; + private readonly Func _nowUtc; + private DateTime _lastPublishedUtc = DateTime.MinValue; + + public AxoCauseAnalyzer( + Func> source, + AxoCauseAnalyzerOptions? options = null, + Func? nowUtc = null) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _options = options ?? new AxoCauseAnalyzerOptions(); + _nowUtc = nowUtc ?? (() => DateTime.UtcNow); + } + + public static AxoCauseAnalyzer Create( + AxoMessageProvider provider, + AxoCauseAnalyzerOptions? options = null, + Func? nowUtc = null) + { + if (provider is null) throw new ArgumentNullException(nameof(provider)); + return new AxoCauseAnalyzer( + () => (provider.Messengers ?? Array.Empty()).Select(Adapt), + options, + nowUtc); + } + + private static IRankableMessage Adapt(AxoMessenger m) => + new AxoMessengerRankableAdapter( + symbol: () => m.Symbol, + category: () => (eAxoMessageCategory)m.Category.LastValue, + risenUtc: () => m.Risen.LastValue, + state: () => m.State, + isAcknowledged: () => m.IsAcknowledged, + displayMessage: () => SafeMessageText(m), + senderDisplayName: () => SenderName(m)); + + private static string SafeMessageText(AxoMessenger m) + { + try { return m.GetMessageText(); } + catch { return string.Empty; } + } + + private static string SenderName(AxoMessenger m) + { + var comp = m.Component; + return comp?.GetSymbolTail() ?? m.GetSymbolTail(); + } + + public AxoProbableCause? TopCause { get; private set; } + public IReadOnlyList ProbableCauses { get; private set; } = Array.Empty(); + public int ActiveCount { get; private set; } + public eAxoMessageCategory PeakSeverity { get; private set; } = eAxoMessageCategory.None; + + public event Action? Changed; + + public void Recompute() + { + var active = _source().Where(IsActive).ToList(); + var now = _nowUtc(); + var prevTopSymbol = TopCause?.Message.Symbol; + + if (active.Count == 0) + { + // Hold-cache: a momentary empty read inside HoldDuration of the last + // non-empty publish is treated as PLC-cycle strobe and ignored. + if (TopCause != null && now - _lastPublishedUtc < _options.HoldDuration) + { + return; + } + TopCause = null; + ProbableCauses = Array.Empty(); + PeakSeverity = eAxoMessageCategory.None; + ActiveCount = 0; + RaiseChangedIfTopFlipped(prevTopSymbol); + return; + } + + ActiveCount = active.Count; + PeakSeverity = active.Max(m => m.Category); + + var burstCutoff = active.Max(m => m.RisenUtc) - _options.BurstWindow; + var earliestInBurst = active + .Where(m => m.RisenUtc >= burstCutoff) + .Min(m => m.RisenUtc); + + ProbableCauses = active + .Select(m => + { + 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); + var score = W_SEV * SeverityWeight(m) + + (isBurstRoot ? W_ROOT : 0.0) + + W_OWNER * Math.Log10(1 + downstream) + + (m.IsAcknowledged ? 0.0 : W_UNACK) + - W_AGE * ageMinutes; + return new AxoProbableCause(m, score, isBurstRoot, downstream); + }) + .OrderByDescending(c => c.Score) + .Take(_options.TopN) + .ToList(); + TopCause = ProbableCauses[0]; + _lastPublishedUtc = now; + RaiseChangedIfTopFlipped(prevTopSymbol); + } + + private void RaiseChangedIfTopFlipped(string? previousTopSymbol) + { + var current = TopCause?.Message.Symbol; + if (!string.Equals(previousTopSymbol, current, StringComparison.Ordinal)) + { + Changed?.Invoke(); + } + } + + // Other is descendant of parent if its symbol starts with "parent.". + private static bool IsDescendant(string otherSymbol, string parentSymbol) => + otherSymbol.Length > parentSymbol.Length + 1 && + otherSymbol.StartsWith(parentSymbol, StringComparison.Ordinal) && + otherSymbol[parentSymbol.Length] == '.'; + + private static bool IsActive(IRankableMessage m) => + m.State == eAxoMessengerState.ActiveAcknowledgeRequired || + m.State == eAxoMessengerState.ActiveAcknowledgeNotRequired || + m.State == eAxoMessengerState.ActiveAlreadyAcknowledged; + + // Operator-actionability weights, not enum ordinals. + // Error outranks ProgrammingError: a process Error needs operator action; + // a ProgrammingError is an engineering bug surfaced for diagnostics. + internal static double SeverityWeight(IRankableMessage m) => m.Category switch + { + eAxoMessageCategory.Critical => 1.00, + eAxoMessageCategory.Error => 0.90, + eAxoMessageCategory.ProgrammingError => 0.85, + eAxoMessageCategory.Warning => 0.60, + eAxoMessageCategory.Potential => 0.40, + eAxoMessageCategory.Info => 0.10, + _ => 0.00, + }; + } + + public sealed record AxoProbableCause( + IRankableMessage Message, + double Score, + bool IsBurstRoot, + int DownstreamCount); + + public sealed class AxoCauseAnalyzerOptions + { + public TimeSpan BurstWindow { get; init; } = TimeSpan.FromSeconds(8); + public TimeSpan HoldDuration { get; init; } = TimeSpan.FromSeconds(2); + public TimeSpan IdleHysteresis { get; init; } = TimeSpan.FromSeconds(2); + public int TopN { get; init; } = 5; + } +} diff --git a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoIncidentBarPresenter.cs b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoIncidentBarPresenter.cs new file mode 100644 index 000000000..1f7b4936f --- /dev/null +++ b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoIncidentBarPresenter.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AXOpen.Messaging.Static +{ + public enum IncidentBarSeverity + { + None, + Info, + Warning, + Danger, + } + + public sealed class AxoIncidentBarState + { + public bool IsVisible { get; init; } + public bool Pulses { get; init; } + public IncidentBarSeverity Severity { get; init; } = IncidentBarSeverity.None; + public AxoProbableCause? TopCause { get; init; } + public IReadOnlyList Rows { get; init; } = Array.Empty(); + public int AdditionalCount { get; init; } + } + + public sealed record AxoIncidentBarRow( + AxoProbableCause Cause, + bool IsAckPending); + + public sealed class AxoIncidentBarPresenter + { + private readonly AxoCauseAnalyzer _analyzer; + private readonly TimeSpan _idleHysteresis; + private readonly Func _nowUtc; + private readonly HashSet _ackPending = new(StringComparer.Ordinal); + private DateTime _lastNonEmptyAt = DateTime.MinValue; + private bool _everHadTopCause; + + public AxoIncidentBarPresenter( + AxoCauseAnalyzer analyzer, + TimeSpan? idleHysteresis = null, + Func? nowUtc = null) + { + _analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer)); + _idleHysteresis = idleHysteresis ?? TimeSpan.FromSeconds(2); + _nowUtc = nowUtc ?? (() => DateTime.UtcNow); + } + + public event Action? StateChanged; + + public AxoIncidentBarState CurrentState => Compute(); + + public void Refresh() + { + // Recompute pulls fresh analyzer values; the bar component triggers this after a poll tick. + _ = Compute(); + StateChanged?.Invoke(); + } + + public void NotifyAckPending(IRankableMessage message) + { + if (message is null) return; + _ackPending.Add(message.Symbol); + } + + public void NotifyAckResolved(IRankableMessage message) + { + if (message is null) return; + _ackPending.Remove(message.Symbol); + } + + private AxoIncidentBarState Compute() + { + var now = _nowUtc(); + var top = _analyzer.TopCause; + + if (top != null) + { + _lastNonEmptyAt = now; + _everHadTopCause = true; + return new AxoIncidentBarState + { + IsVisible = true, + Pulses = ShouldPulse(top.Message), + Severity = ToSeverityBucket(top.Message.Category), + TopCause = top, + Rows = _analyzer.ProbableCauses + .Select(c => new AxoIncidentBarRow(c, _ackPending.Contains(c.Message.Symbol))) + .ToArray(), + AdditionalCount = Math.Max(0, _analyzer.ProbableCauses.Count - 1), + }; + } + + // Top cleared — apply idle hysteresis. + if (_everHadTopCause && now - _lastNonEmptyAt < _idleHysteresis) + { + return new AxoIncidentBarState + { + IsVisible = true, + Severity = IncidentBarSeverity.None, + }; + } + + return new AxoIncidentBarState(); + } + + private static bool ShouldPulse(IRankableMessage m) => + !m.IsAcknowledged && (m.Category == eAxoMessageCategory.Critical || m.Category == eAxoMessageCategory.ProgrammingError); + + public static IncidentBarSeverity ToSeverityBucket(eAxoMessageCategory category) => category switch + { + eAxoMessageCategory.Critical => IncidentBarSeverity.Danger, + eAxoMessageCategory.ProgrammingError => IncidentBarSeverity.Danger, + eAxoMessageCategory.Error => IncidentBarSeverity.Danger, + eAxoMessageCategory.Warning => IncidentBarSeverity.Warning, + eAxoMessageCategory.Potential => IncidentBarSeverity.Info, + eAxoMessageCategory.Info => IncidentBarSeverity.Info, + _ => IncidentBarSeverity.None, + }; + + // Tailwind tokens shared with AxoMessengerView's severity treatment. + public static string GlowClass(IncidentBarSeverity sev) => sev switch + { + IncidentBarSeverity.Danger => "shadow-glow-danger", + IncidentBarSeverity.Warning => "shadow-glow-warning", + IncidentBarSeverity.Info => "shadow-glow-info", + _ => string.Empty, + }; + + public static string BadgeClass(IncidentBarSeverity sev) => sev switch + { + IncidentBarSeverity.Danger => "badge badge-danger", + IncidentBarSeverity.Warning => "badge badge-warning", + IncidentBarSeverity.Info => "badge badge-primary", + _ => string.Empty, + }; + + public static string BackgroundClass(IncidentBarSeverity sev) => sev switch + { + IncidentBarSeverity.Danger => "bg-linear-to-br from-danger/20! from-0% to-background-light! to-50%", + IncidentBarSeverity.Warning => "bg-linear-to-br from-warning/20! from-0% to-background-light! to-50%", + IncidentBarSeverity.Info => "bg-linear-to-br from-info/20! from-0% to-background-light! to-50%", + _ => string.Empty, + }; + } +} diff --git a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessengerRankableAdapter.cs b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessengerRankableAdapter.cs new file mode 100644 index 000000000..8b90f5a29 --- /dev/null +++ b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessengerRankableAdapter.cs @@ -0,0 +1,41 @@ +using System; + +namespace AXOpen.Messaging.Static +{ + public sealed class AxoMessengerRankableAdapter : IRankableMessage + { + private readonly Func _symbol; + private readonly Func _category; + private readonly Func _risenUtc; + private readonly Func _state; + private readonly Func _isAcknowledged; + private readonly Func _displayMessage; + private readonly Func _senderDisplayName; + + public AxoMessengerRankableAdapter( + Func symbol, + Func category, + Func risenUtc, + Func state, + Func isAcknowledged, + Func displayMessage, + Func senderDisplayName) + { + _symbol = symbol ?? throw new ArgumentNullException(nameof(symbol)); + _category = category ?? throw new ArgumentNullException(nameof(category)); + _risenUtc = risenUtc ?? throw new ArgumentNullException(nameof(risenUtc)); + _state = state ?? throw new ArgumentNullException(nameof(state)); + _isAcknowledged = isAcknowledged ?? throw new ArgumentNullException(nameof(isAcknowledged)); + _displayMessage = displayMessage ?? throw new ArgumentNullException(nameof(displayMessage)); + _senderDisplayName = senderDisplayName ?? throw new ArgumentNullException(nameof(senderDisplayName)); + } + + public string Symbol => _symbol(); + public eAxoMessageCategory Category => _category(); + public DateTime RisenUtc => _risenUtc(); + public eAxoMessengerState State => _state(); + public bool IsAcknowledged => _isAcknowledged(); + public string DisplayMessage => _displayMessage(); + public string SenderDisplayName => _senderDisplayName(); + } +} diff --git a/src/core/src/AXOpen.Core/AxoMessenger/Static/IRankableMessage.cs b/src/core/src/AXOpen.Core/AxoMessenger/Static/IRankableMessage.cs new file mode 100644 index 000000000..7d8326143 --- /dev/null +++ b/src/core/src/AXOpen.Core/AxoMessenger/Static/IRankableMessage.cs @@ -0,0 +1,15 @@ +using System; + +namespace AXOpen.Messaging.Static +{ + public interface IRankableMessage + { + string Symbol { get; } + eAxoMessageCategory Category { get; } + DateTime RisenUtc { get; } + eAxoMessengerState State { get; } + bool IsAcknowledged { get; } + string DisplayMessage { get; } + string SenderDisplayName { get; } + } +} diff --git a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerFactoryTests.cs b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerFactoryTests.cs new file mode 100644 index 000000000..fe861e1c6 --- /dev/null +++ b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerFactoryTests.cs @@ -0,0 +1,29 @@ +using System; +using AXOpen.Messaging.Static; +using AXSharp.Connector; +using Xunit; + +namespace axopen_core_tests.Messaging +{ + public class AxoCauseAnalyzerFactoryTests + { + [Fact] + public void Create_throws_when_provider_is_null() + { + Assert.Throws(() => + AxoCauseAnalyzer.Create(provider: null!)); + } + + [Fact] + public void Create_wraps_provider_with_zero_observed_objects_yields_empty_analyzer() + { + var provider = AxoMessageProvider.Create(Array.Empty()); + var analyzer = AxoCauseAnalyzer.Create(provider); + + analyzer.Recompute(); + + Assert.Null(analyzer.TopCause); + Assert.Equal(0, analyzer.ActiveCount); + } + } +} diff --git a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs new file mode 100644 index 000000000..ac8f9c5b6 --- /dev/null +++ b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AXOpen.Messaging; +using AXOpen.Messaging.Static; +using Xunit; + +namespace axopen_core_tests.Messaging +{ + public class AxoCauseAnalyzerTests + { + private static FakeMsg Msg( + string symbol, + eAxoMessageCategory category = eAxoMessageCategory.Warning, + DateTime? risen = null, + eAxoMessengerState state = eAxoMessengerState.ActiveAcknowledgeRequired, + bool acked = false) + => new( + Symbol: symbol, + Category: category, + RisenUtc: risen ?? new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc), + State: state, + IsAcknowledged: acked, + DisplayMessage: symbol, + SenderDisplayName: symbol); + + private sealed record FakeMsg( + string Symbol, + eAxoMessageCategory Category, + DateTime RisenUtc, + eAxoMessengerState State, + bool IsAcknowledged, + string DisplayMessage, + string SenderDisplayName) : IRankableMessage; + + [Fact] + public void Empty_set_yields_no_top_cause() + { + var analyzer = new AxoCauseAnalyzer(Array.Empty); + + analyzer.Recompute(); + + Assert.Null(analyzer.TopCause); + Assert.Empty(analyzer.ProbableCauses); + Assert.Equal(0, analyzer.ActiveCount); + Assert.Equal(eAxoMessageCategory.None, analyzer.PeakSeverity); + } + + [Fact] + public void Single_active_critical_becomes_top() + { + var critical = Msg("Plc.Tank.Pressure", eAxoMessageCategory.Critical); + var analyzer = new AxoCauseAnalyzer(() => new[] { critical }); + + analyzer.Recompute(); + + Assert.NotNull(analyzer.TopCause); + Assert.Same(critical, analyzer.TopCause!.Message); + Assert.Equal(1, analyzer.ActiveCount); + Assert.Equal(eAxoMessageCategory.Critical, analyzer.PeakSeverity); + Assert.Single(analyzer.ProbableCauses); + } + + [Fact] + public void Critical_outranks_error_when_burst_and_topology_tied() + { + var risen = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var error = Msg("Plc.A", eAxoMessageCategory.Error, risen); + var critical = Msg("Plc.B", eAxoMessageCategory.Critical, risen); + + // Critical declared LAST in input order to prove ordering is by score, not insertion. + var analyzer = new AxoCauseAnalyzer(() => new[] { error, critical }); + analyzer.Recompute(); + + Assert.Same(critical, analyzer.TopCause!.Message); + Assert.Equal(2, analyzer.ProbableCauses.Count); + Assert.Same(critical, analyzer.ProbableCauses[0].Message); + Assert.Same(error, analyzer.ProbableCauses[1].Message); + } + + // Operator-actionability map: Error (0.9) ranks ABOVE ProgrammingError (0.85), + // even though enum ordinal 400 > 300. Encodes intent over enum. + [Fact] + public void Error_outranks_programming_error_per_explicit_severity_map() + { + var risen = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var error = Msg("Plc.A", eAxoMessageCategory.Error, risen); + var progErr = Msg("Plc.B", eAxoMessageCategory.ProgrammingError, risen); + + var analyzer = new AxoCauseAnalyzer(() => new[] { progErr, error }); + analyzer.Recompute(); + + Assert.Same(error, analyzer.TopCause!.Message); + } + + [Fact] + public void Earliest_in_burst_window_wins_burst_root_flag() + { + var t0 = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var earliest = Msg("Plc.A", eAxoMessageCategory.Error, t0); + var later = Msg("Plc.B", eAxoMessageCategory.Error, t0.AddSeconds(2)); + + var analyzer = new AxoCauseAnalyzer(() => new[] { later, earliest }); + analyzer.Recompute(); + + // earliest gets root flag and the W_root bonus, so it sorts to top + Assert.Same(earliest, analyzer.TopCause!.Message); + Assert.True(analyzer.TopCause.IsBurstRoot); + Assert.False(analyzer.ProbableCauses[1].IsBurstRoot); + } + + // Among non-burst-root messages of equal severity, age decay must order + // the fresher alarm above the stale one. Operators should focus on what + // changed recently, not what has been ringing for an hour. + [Fact] + public void Age_decay_demotes_long_running_alarm_below_newer_peer() + { + var now = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var stale = Msg("Plc.Stale", eAxoMessageCategory.Warning, now.AddMinutes(-60)); + var fresh = Msg("Plc.Fresh", eAxoMessageCategory.Warning, now.AddMinutes(-10)); + var anchor = Msg("Plc.Anchor", eAxoMessageCategory.Warning, now); // window anchor, becomes root + + var analyzer = new AxoCauseAnalyzer( + () => new[] { stale, fresh, anchor }, + nowUtc: () => now); + analyzer.Recompute(); + + var byMsg = analyzer.ProbableCauses.ToDictionary(c => c.Message); + // anchor is root, top regardless of age + Assert.Same(anchor, analyzer.TopCause!.Message); + // among non-root peers, fresher wins + Assert.True(byMsg[fresh].Score > byMsg[stale].Score, + $"fresh ({byMsg[fresh].Score}) must outrank stale ({byMsg[stale].Score})"); + } + + [Fact] + public void Changed_event_fires_only_on_top_cause_symbol_flip() + { + var t = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var a = Msg("Plc.A", eAxoMessageCategory.Critical, t); + var b = Msg("Plc.B", eAxoMessageCategory.Critical, t); + + IEnumerable source = Array.Empty(); + var now = t; + var analyzer = new AxoCauseAnalyzer( + () => source, + options: new AxoCauseAnalyzerOptions { HoldDuration = TimeSpan.Zero }, + nowUtc: () => now); + + var fires = 0; + analyzer.Changed += () => fires++; + + // First publish (null → A) — flip + source = new[] { a }; + analyzer.Recompute(); + Assert.Equal(1, fires); + + // Same A — no flip + analyzer.Recompute(); + Assert.Equal(1, fires); + + // Symbol flips A → B + source = new[] { b }; + analyzer.Recompute(); + Assert.Equal(2, fires); + + // Same B — no flip + analyzer.Recompute(); + Assert.Equal(2, fires); + + // B → null (HoldDuration=0 lets the empty publish through) + source = Array.Empty(); + analyzer.Recompute(); + Assert.Equal(3, fires); + } + + [Fact] + public void HoldDuration_suppresses_rank_strobe() + { + // Simulates mid-PLC-cycle read where active count briefly drops to 0 + // before the next cycle re-populates. Bar must not flicker. + var risen = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var critical = Msg("Plc.X", eAxoMessageCategory.Critical, risen); + IEnumerable source = new[] { critical }; + + var now = risen; + var analyzer = new AxoCauseAnalyzer( + () => source, + options: new AxoCauseAnalyzerOptions { HoldDuration = TimeSpan.FromSeconds(2) }, + nowUtc: () => now); + + analyzer.Recompute(); + Assert.Same(critical, analyzer.TopCause!.Message); + + // 0.5s later, source momentarily empty — held + now = risen.AddSeconds(0.5); + source = Array.Empty(); + analyzer.Recompute(); + Assert.Same(critical, analyzer.TopCause!.Message); + + // 3s later, hold expired — clears + now = risen.AddSeconds(3); + analyzer.Recompute(); + Assert.Null(analyzer.TopCause); + } + + [Fact] + public void TopN_clamps_published_list() + { + var t = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var msgs = Enumerable.Range(0, 10) + .Select(i => Msg($"Plc.M{i:00}", eAxoMessageCategory.Warning, t.AddSeconds(i))) + .ToArray(); + + var analyzer = new AxoCauseAnalyzer( + () => msgs, + options: new AxoCauseAnalyzerOptions { TopN = 3 }); + analyzer.Recompute(); + + Assert.Equal(10, analyzer.ActiveCount); + Assert.Equal(3, analyzer.ProbableCauses.Count); + } + + [Fact] + public void Acked_active_is_listed_but_scored_lower_than_equivalent_unacked() + { + var t = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var acked = Msg("Plc.A", eAxoMessageCategory.Error, t, + state: eAxoMessengerState.ActiveAlreadyAcknowledged, acked: true); + var unacked = Msg("Plc.B", eAxoMessageCategory.Error, t, + state: eAxoMessengerState.ActiveAcknowledgeRequired, acked: false); + + var analyzer = new AxoCauseAnalyzer(() => new[] { acked, unacked }); + analyzer.Recompute(); + + Assert.Equal(2, analyzer.ProbableCauses.Count); + Assert.Same(unacked, analyzer.TopCause!.Message); + Assert.True(analyzer.ProbableCauses[0].Score > analyzer.ProbableCauses[1].Score); + } + + [Fact] + public void Symbol_prefix_owner_gets_downstream_count() + { + var t = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var station = Msg("Plc.Station", eAxoMessageCategory.Error, t); + var drive = Msg("Plc.Station.Drive", eAxoMessageCategory.Error, t); + var encoder = Msg("Plc.Station.Drive.Enc", eAxoMessageCategory.Error, t); + var unrelated = Msg("Plc.Conveyor", eAxoMessageCategory.Error, t); + + var analyzer = new AxoCauseAnalyzer(() => new[] { station, drive, encoder, unrelated }); + analyzer.Recompute(); + + var byMsg = analyzer.ProbableCauses.ToDictionary(c => c.Message); + Assert.Equal(2, byMsg[station].DownstreamCount); + Assert.Equal(1, byMsg[drive].DownstreamCount); + Assert.Equal(0, byMsg[encoder].DownstreamCount); + Assert.Equal(0, byMsg[unrelated].DownstreamCount); + } + + // Default burst window = 8s ending at latest Risen. + // A message older than the window is NOT eligible to be burst root, + // even though it's globally the earliest. + [Fact] + public void Burst_window_is_sliding_from_latest_risen() + { + var t0 = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var old = Msg("Plc.A", eAxoMessageCategory.Error, t0); // 15s before latest, OUT + var earlyBurst = Msg("Plc.B", eAxoMessageCategory.Error, t0.AddSeconds(10)); // within 8s window, IN + var latest = Msg("Plc.C", eAxoMessageCategory.Error, t0.AddSeconds(15)); // window anchor + + var analyzer = new AxoCauseAnalyzer(() => new[] { old, earlyBurst, latest }); + analyzer.Recompute(); + + var byMsg = analyzer.ProbableCauses.ToDictionary(c => c.Message); + Assert.False(byMsg[old].IsBurstRoot, "old message must NOT be burst root (outside window)"); + Assert.True (byMsg[earlyBurst].IsBurstRoot, "earliest IN window must be burst root"); + Assert.False(byMsg[latest].IsBurstRoot, "latest is window anchor, not earliest"); + } + } +} diff --git a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoIncidentBarPresenterTests.cs b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoIncidentBarPresenterTests.cs new file mode 100644 index 000000000..f3058a49f --- /dev/null +++ b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoIncidentBarPresenterTests.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AXOpen.Messaging; +using AXOpen.Messaging.Static; +using Xunit; + +namespace axopen_core_tests.Messaging +{ + public class AxoIncidentBarPresenterTests + { + private static FakeMsg Msg( + string symbol, + eAxoMessageCategory category = eAxoMessageCategory.Warning, + DateTime? risen = null, + eAxoMessengerState state = eAxoMessengerState.ActiveAcknowledgeRequired, + bool acked = false) + => new( + Symbol: symbol, + Category: category, + RisenUtc: risen ?? new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc), + State: state, + IsAcknowledged: acked, + DisplayMessage: symbol, + SenderDisplayName: symbol); + + private sealed record FakeMsg( + string Symbol, + eAxoMessageCategory Category, + DateTime RisenUtc, + eAxoMessengerState State, + bool IsAcknowledged, + string DisplayMessage, + string SenderDisplayName) : IRankableMessage; + + private static (AxoCauseAnalyzer analyzer, Action> setSource, Action setNow) + BuildAnalyzer(TimeSpan? hold = null) + { + IEnumerable source = Array.Empty(); + DateTime now = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var analyzer = new AxoCauseAnalyzer( + () => source, + options: new AxoCauseAnalyzerOptions { HoldDuration = hold ?? TimeSpan.Zero }, + nowUtc: () => now); + return (analyzer, s => source = s, t => now = t); + } + + [Fact] + public void Bar_is_hidden_when_active_count_is_zero() + { + var (analyzer, _, _) = BuildAnalyzer(); + analyzer.Recompute(); + + var presenter = new AxoIncidentBarPresenter(analyzer); + + Assert.False(presenter.CurrentState.IsVisible); + Assert.Null(presenter.CurrentState.TopCause); + Assert.Equal(IncidentBarSeverity.None, presenter.CurrentState.Severity); + } + + [Fact] + public void Bar_is_visible_when_top_cause_exists() + { + var (analyzer, setSource, _) = BuildAnalyzer(); + setSource(new[] { Msg("Plc.X", eAxoMessageCategory.Warning) }); + analyzer.Recompute(); + + var presenter = new AxoIncidentBarPresenter(analyzer); + + Assert.True(presenter.CurrentState.IsVisible); + Assert.NotNull(presenter.CurrentState.TopCause); + } + + [Theory] + [InlineData(eAxoMessageCategory.Critical, IncidentBarSeverity.Danger)] + [InlineData(eAxoMessageCategory.Error, IncidentBarSeverity.Danger)] + [InlineData(eAxoMessageCategory.ProgrammingError, IncidentBarSeverity.Danger)] + [InlineData(eAxoMessageCategory.Warning, IncidentBarSeverity.Warning)] + [InlineData(eAxoMessageCategory.Potential, IncidentBarSeverity.Info)] + [InlineData(eAxoMessageCategory.Info, IncidentBarSeverity.Info)] + public void Severity_maps_category_to_visual_bucket(eAxoMessageCategory cat, IncidentBarSeverity expected) + { + var (analyzer, setSource, _) = BuildAnalyzer(); + setSource(new[] { Msg("Plc.X", cat) }); + analyzer.Recompute(); + + var presenter = new AxoIncidentBarPresenter(analyzer); + + Assert.Equal(expected, presenter.CurrentState.Severity); + } + + [Theory] + [InlineData(eAxoMessageCategory.Critical, true)] + [InlineData(eAxoMessageCategory.ProgrammingError, true)] + [InlineData(eAxoMessageCategory.Error, false)] // pulse reserved for system-critical only + [InlineData(eAxoMessageCategory.Warning, false)] + [InlineData(eAxoMessageCategory.Info, false)] + public void Pulse_only_for_critical_class_when_top_not_acked(eAxoMessageCategory cat, bool expectedPulse) + { + var (analyzer, setSource, _) = BuildAnalyzer(); + setSource(new[] { Msg("Plc.X", cat) }); + analyzer.Recompute(); + + var presenter = new AxoIncidentBarPresenter(analyzer); + + Assert.Equal(expectedPulse, presenter.CurrentState.Pulses); + } + + [Fact] + public void Pulse_disabled_when_top_cause_is_acknowledged() + { + var (analyzer, setSource, _) = BuildAnalyzer(); + setSource(new[] { Msg("Plc.X", eAxoMessageCategory.Critical, + state: eAxoMessengerState.ActiveAlreadyAcknowledged, acked: true) }); + analyzer.Recompute(); + + var presenter = new AxoIncidentBarPresenter(analyzer); + + Assert.True(presenter.CurrentState.IsVisible); + Assert.False(presenter.CurrentState.Pulses); + } + + [Fact] + public void Additional_count_equals_visible_rows_minus_one() + { + var t = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var (analyzer, setSource, _) = BuildAnalyzer(); + setSource(new[] + { + Msg("Plc.A", eAxoMessageCategory.Error, t), + Msg("Plc.B", eAxoMessageCategory.Warning, t), + Msg("Plc.C", eAxoMessageCategory.Warning, t), + }); + analyzer.Recompute(); + + var presenter = new AxoIncidentBarPresenter(analyzer); + + Assert.Equal(3, presenter.CurrentState.Rows.Count); + Assert.Equal(2, presenter.CurrentState.AdditionalCount); // 3 rows - 1 top + } + + [Fact] + public void Rows_match_analyzer_probable_causes_in_order() + { + var t = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var a = Msg("Plc.A", eAxoMessageCategory.Warning, t); + var b = Msg("Plc.B", eAxoMessageCategory.Critical, t); + + var (analyzer, setSource, _) = BuildAnalyzer(); + setSource(new[] { a, b }); + analyzer.Recompute(); + + var presenter = new AxoIncidentBarPresenter(analyzer); + + Assert.Equal(2, presenter.CurrentState.Rows.Count); + Assert.Same(b, presenter.CurrentState.Rows[0].Cause.Message); + Assert.Same(a, presenter.CurrentState.Rows[1].Cause.Message); + } + + [Fact] + public void Ack_pending_marker_persists_until_resolved() + { + var critical = Msg("Plc.X", eAxoMessageCategory.Critical); + var (analyzer, setSource, _) = BuildAnalyzer(); + setSource(new[] { critical }); + analyzer.Recompute(); + + var presenter = new AxoIncidentBarPresenter(analyzer); + Assert.False(presenter.CurrentState.Rows[0].IsAckPending); + + presenter.NotifyAckPending(critical); + Assert.True(presenter.CurrentState.Rows[0].IsAckPending); + + presenter.NotifyAckResolved(critical); + Assert.False(presenter.CurrentState.Rows[0].IsAckPending); + } + + [Theory] + [InlineData(IncidentBarSeverity.Danger, "shadow-glow-danger")] + [InlineData(IncidentBarSeverity.Warning, "shadow-glow-warning")] + [InlineData(IncidentBarSeverity.Info, "shadow-glow-info")] + [InlineData(IncidentBarSeverity.None, "")] + public void Glow_class_matches_severity_bucket(IncidentBarSeverity sev, string expected) + { + Assert.Equal(expected, AxoIncidentBarPresenter.GlowClass(sev)); + } + + [Theory] + [InlineData(IncidentBarSeverity.Danger, "badge badge-danger")] + [InlineData(IncidentBarSeverity.Warning, "badge badge-warning")] + [InlineData(IncidentBarSeverity.Info, "badge badge-primary")] + [InlineData(IncidentBarSeverity.None, "")] + public void Badge_class_matches_severity_bucket(IncidentBarSeverity sev, string expected) + { + Assert.Equal(expected, AxoIncidentBarPresenter.BadgeClass(sev)); + } + + [Fact] + public void Idle_hysteresis_keeps_bar_visible_after_top_cause_clears() + { + var t0 = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var now = t0; + IEnumerable source = new[] { Msg("Plc.X", eAxoMessageCategory.Warning, t0) }; + + var analyzer = new AxoCauseAnalyzer( + () => source, + options: new AxoCauseAnalyzerOptions { HoldDuration = TimeSpan.Zero }, + nowUtc: () => now); + analyzer.Recompute(); + + var presenter = new AxoIncidentBarPresenter( + analyzer, + idleHysteresis: TimeSpan.FromSeconds(2), + nowUtc: () => now); + Assert.True(presenter.CurrentState.IsVisible); + + // Clear and refresh at t+0.5s — still visible (within hysteresis) + source = Array.Empty(); + now = t0.AddSeconds(0.5); + analyzer.Recompute(); + presenter.Refresh(); + Assert.True(presenter.CurrentState.IsVisible); + + // At t+3s, past hysteresis — gone + now = t0.AddSeconds(3); + presenter.Refresh(); + Assert.False(presenter.CurrentState.IsVisible); + } + } +} diff --git a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoMessengerRankableAdapterTests.cs b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoMessengerRankableAdapterTests.cs new file mode 100644 index 000000000..23629b8d1 --- /dev/null +++ b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoMessengerRankableAdapterTests.cs @@ -0,0 +1,60 @@ +using System; +using AXOpen.Messaging; +using AXOpen.Messaging.Static; +using Xunit; + +namespace axopen_core_tests.Messaging +{ + public class AxoMessengerRankableAdapterTests + { + [Fact] + public void Adapter_projects_each_field_from_supplied_delegates() + { + var risen = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + + var adapter = new AxoMessengerRankableAdapter( + symbol: () => "Plc.Tank.Pressure", + category: () => eAxoMessageCategory.Critical, + risenUtc: () => risen, + state: () => eAxoMessengerState.ActiveAcknowledgeRequired, + isAcknowledged: () => false, + displayMessage: () => "Tank pressure above safe limit", + senderDisplayName: () => "Tank"); + + Assert.Equal("Plc.Tank.Pressure", adapter.Symbol); + Assert.Equal(eAxoMessageCategory.Critical, adapter.Category); + Assert.Equal(risen, adapter.RisenUtc); + Assert.Equal(eAxoMessengerState.ActiveAcknowledgeRequired, adapter.State); + Assert.False(adapter.IsAcknowledged); + Assert.Equal("Tank pressure above safe limit", adapter.DisplayMessage); + Assert.Equal("Tank", adapter.SenderDisplayName); + } + + // Delegates must be re-invoked on each access so the adapter sees the latest + // batch-read value, not a stale snapshot captured at construction. + [Fact] + public void Adapter_re_invokes_delegates_on_each_access() + { + var state = eAxoMessengerState.ActiveAcknowledgeRequired; + var acked = false; + + var adapter = new AxoMessengerRankableAdapter( + symbol: () => "X", + category: () => eAxoMessageCategory.Error, + risenUtc: () => DateTime.UtcNow, + state: () => state, + isAcknowledged: () => acked, + displayMessage: () => "", + senderDisplayName: () => ""); + + Assert.False(adapter.IsAcknowledged); + Assert.Equal(eAxoMessengerState.ActiveAcknowledgeRequired, adapter.State); + + state = eAxoMessengerState.ActiveAlreadyAcknowledged; + acked = true; + + Assert.True(adapter.IsAcknowledged); + Assert.Equal(eAxoMessengerState.ActiveAlreadyAcknowledged, adapter.State); + } + } +} From bf64be7b8505a4764021a169ce25e922cdbfbc20 Mon Sep 17 00:00:00 2001 From: Peter Kurhajec <61538034+PTKu@users.noreply.github.com> Date: Wed, 27 May 2026 12:02:06 +0200 Subject: [PATCH 2/4] Refactor code structure for improved readability and maintainability --- .../Static/AxoIncidentBarView.razor | 25 ++----- .../AxoMessenger/Static/AxoCauseAnalyzer.cs | 30 +++++--- .../Static/AxoIncidentBarPresenter.cs | 29 +++++--- .../Messaging/AxoCauseAnalyzerTests.cs | 55 ++++++++++++-- .../Messaging/AxoIncidentBarPresenterTests.cs | 72 +++++++++++++------ src/styling/src/wwwroot/css/momentum.css | 2 +- 6 files changed, 149 insertions(+), 64 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 9fb943efc..478c5863c 100644 --- a/src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/AxoIncidentBarView.razor +++ b/src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/AxoIncidentBarView.razor @@ -20,11 +20,7 @@ class="card w-full @AxoIncidentBarPresenter.GlowClass(sev) @AxoIncidentBarPresenter.BackgroundClass(sev) @pulse @Class">
-
- @if (!string.IsNullOrWhiteSpace(PlcLabel)) - { - @PlcLabel - } +
@SeverityName(sev) @if (_state.TopCause is { } top) { @@ -37,14 +33,14 @@ { +@_state.AdditionalCount } - @if (_state.TopCause is not null) + @* @if (_state.TopCause is not null) { - } + } *@ @@ -70,17 +66,7 @@ score @row.Cause.Score.ToString("F2", CultureInfo.InvariantCulture)
- } - - - @if (AllowRestore && _state.TopCause is not null) - { -
- -
- } -
-
+ }
} @@ -208,9 +194,10 @@ private static string SeverityName(IncidentBarSeverity sev) => sev switch { - IncidentBarSeverity.Danger => "Critical", + IncidentBarSeverity.Error => "Error", IncidentBarSeverity.Warning => "Warning", IncidentBarSeverity.Info => "Info", + IncidentBarSeverity.Critical => "Critical", _ => string.Empty, }; diff --git a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs index d913fd41c..5cbd92b8a 100644 --- a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs +++ b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs @@ -74,7 +74,16 @@ public void Recompute() var now = _nowUtc(); var prevTopSymbol = TopCause?.Message.Symbol; - if (active.Count == 0) + // Always-accurate global stats — independent of the cause floor. + ActiveCount = active.Count; + PeakSeverity = active.Count == 0 ? eAxoMessageCategory.None : active.Max(m => m.Category); + + // 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(); + + if (candidates.Count == 0) { // Hold-cache: a momentary empty read inside HoldDuration of the last // non-empty publish is treated as PLC-cycle strobe and ignored. @@ -84,21 +93,16 @@ public void Recompute() } TopCause = null; ProbableCauses = Array.Empty(); - PeakSeverity = eAxoMessageCategory.None; - ActiveCount = 0; RaiseChangedIfTopFlipped(prevTopSymbol); return; } - ActiveCount = active.Count; - PeakSeverity = active.Max(m => m.Category); - - var burstCutoff = active.Max(m => m.RisenUtc) - _options.BurstWindow; - var earliestInBurst = active + var burstCutoff = candidates.Max(m => m.RisenUtc) - _options.BurstWindow; + var earliestInBurst = candidates .Where(m => m.RisenUtc >= burstCutoff) .Min(m => m.RisenUtc); - ProbableCauses = active + ProbableCauses = candidates .Select(m => { var isBurstRoot = m.RisenUtc == earliestInBurst && m.RisenUtc >= burstCutoff; @@ -166,5 +170,13 @@ public sealed class AxoCauseAnalyzerOptions public TimeSpan HoldDuration { get; init; } = TimeSpan.FromSeconds(2); public TimeSpan IdleHysteresis { get; init; } = TimeSpan.FromSeconds(2); public int TopN { get; init; } = 5; + + /// + /// Minimum category for a message to enter the cause ranking. + /// Messages below this threshold still count toward ActiveCount / PeakSeverity + /// (so global indicators stay accurate) but never appear as probable causes. + /// Default: Error. + /// + public eAxoMessageCategory CauseSeverityFloor { get; init; } = eAxoMessageCategory.Error; } } diff --git a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoIncidentBarPresenter.cs b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoIncidentBarPresenter.cs index 1f7b4936f..031b2f773 100644 --- a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoIncidentBarPresenter.cs +++ b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoIncidentBarPresenter.cs @@ -8,8 +8,9 @@ public enum IncidentBarSeverity { None, Info, - Warning, - Danger, + Warning, + Error, + Critical, } public sealed class AxoIncidentBarState @@ -108,9 +109,9 @@ private static bool ShouldPulse(IRankableMessage m) => public static IncidentBarSeverity ToSeverityBucket(eAxoMessageCategory category) => category switch { - eAxoMessageCategory.Critical => IncidentBarSeverity.Danger, - eAxoMessageCategory.ProgrammingError => IncidentBarSeverity.Danger, - eAxoMessageCategory.Error => IncidentBarSeverity.Danger, + eAxoMessageCategory.Critical => IncidentBarSeverity.Critical, + eAxoMessageCategory.ProgrammingError => IncidentBarSeverity.Error, + eAxoMessageCategory.Error => IncidentBarSeverity.Error, eAxoMessageCategory.Warning => IncidentBarSeverity.Warning, eAxoMessageCategory.Potential => IncidentBarSeverity.Info, eAxoMessageCategory.Info => IncidentBarSeverity.Info, @@ -120,7 +121,8 @@ private static bool ShouldPulse(IRankableMessage m) => // Tailwind tokens shared with AxoMessengerView's severity treatment. public static string GlowClass(IncidentBarSeverity sev) => sev switch { - IncidentBarSeverity.Danger => "shadow-glow-danger", + IncidentBarSeverity.Critical => "shadow-glow-danger", + IncidentBarSeverity.Error => "shadow-glow-danger", IncidentBarSeverity.Warning => "shadow-glow-warning", IncidentBarSeverity.Info => "shadow-glow-info", _ => string.Empty, @@ -128,18 +130,23 @@ private static bool ShouldPulse(IRankableMessage m) => public static string BadgeClass(IncidentBarSeverity sev) => sev switch { - IncidentBarSeverity.Danger => "badge badge-danger", + IncidentBarSeverity.Critical => "badge badge-danger", + IncidentBarSeverity.Error => "badge badge-danger", IncidentBarSeverity.Warning => "badge badge-warning", IncidentBarSeverity.Info => "badge badge-primary", _ => string.Empty, }; + // Flat tint with the same color token as the glow — no fade to neutral. + // Uses /15 opacity step (precompiled in the template's momentum.css for danger/warning/info) + // so the class actually renders without a Tailwind rebuild that scans this assembly's sources. public static string BackgroundClass(IncidentBarSeverity sev) => sev switch { - IncidentBarSeverity.Danger => "bg-linear-to-br from-danger/20! from-0% to-background-light! to-50%", - IncidentBarSeverity.Warning => "bg-linear-to-br from-warning/20! from-0% to-background-light! to-50%", - IncidentBarSeverity.Info => "bg-linear-to-br from-info/20! from-0% to-background-light! to-50%", - _ => string.Empty, + IncidentBarSeverity.Critical => "bg-danger/15", + IncidentBarSeverity.Error => "bg-danger/15", + IncidentBarSeverity.Warning => "bg-warning/15", + IncidentBarSeverity.Info => "bg-info/15", + _ => string.Empty, }; } } diff --git a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs index ac8f9c5b6..be834ed2e 100644 --- a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs +++ b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs @@ -33,6 +33,53 @@ private sealed record FakeMsg( string DisplayMessage, string SenderDisplayName) : IRankableMessage; + // Severity floor: only Error+ messages are considered probable causes by default. + // Warning/Potential/Info still count in ActiveCount/PeakSeverity for the global indicator, + // but the incident bar will not surface them as 'causes' (operator-noise reduction). + [Fact] + public void Default_cause_severity_floor_excludes_below_error() + { + var t = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); + var warning = Msg("Plc.A", eAxoMessageCategory.Warning, t); + var info = Msg("Plc.B", eAxoMessageCategory.Info, t); + var error = Msg("Plc.C", eAxoMessageCategory.Error, t); + + var analyzer = new AxoCauseAnalyzer(() => new[] { warning, info, error }); + analyzer.Recompute(); + + Assert.Equal(3, analyzer.ActiveCount); + Assert.Equal(eAxoMessageCategory.Error, analyzer.PeakSeverity); + // Only the Error is a cause candidate + Assert.Single(analyzer.ProbableCauses); + Assert.Same(error, analyzer.TopCause!.Message); + } + + [Fact] + public void Warning_only_active_yields_no_top_cause() + { + var warning = Msg("Plc.A", eAxoMessageCategory.Warning); + + var analyzer = new AxoCauseAnalyzer(() => new[] { warning }); + analyzer.Recompute(); + + Assert.Equal(1, analyzer.ActiveCount); + Assert.Equal(eAxoMessageCategory.Warning, analyzer.PeakSeverity); + Assert.Null(analyzer.TopCause); + Assert.Empty(analyzer.ProbableCauses); + } + + [Fact] + public void Severity_floor_can_be_lowered_via_options() + { + var warning = Msg("Plc.A", eAxoMessageCategory.Warning); + var analyzer = new AxoCauseAnalyzer( + () => new[] { warning }, + options: new AxoCauseAnalyzerOptions { CauseSeverityFloor = eAxoMessageCategory.Info }); + analyzer.Recompute(); + + Assert.Same(warning, analyzer.TopCause!.Message); + } + [Fact] public void Empty_set_yields_no_top_cause() { @@ -116,9 +163,9 @@ public void Earliest_in_burst_window_wins_burst_root_flag() public void Age_decay_demotes_long_running_alarm_below_newer_peer() { var now = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); - var stale = Msg("Plc.Stale", eAxoMessageCategory.Warning, now.AddMinutes(-60)); - var fresh = Msg("Plc.Fresh", eAxoMessageCategory.Warning, now.AddMinutes(-10)); - var anchor = Msg("Plc.Anchor", eAxoMessageCategory.Warning, now); // window anchor, becomes root + var stale = Msg("Plc.Stale", eAxoMessageCategory.Error, now.AddMinutes(-60)); + var fresh = Msg("Plc.Fresh", eAxoMessageCategory.Error, now.AddMinutes(-10)); + var anchor = Msg("Plc.Anchor", eAxoMessageCategory.Error, now); // window anchor, becomes root var analyzer = new AxoCauseAnalyzer( () => new[] { stale, fresh, anchor }, @@ -209,7 +256,7 @@ public void TopN_clamps_published_list() { var t = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); var msgs = Enumerable.Range(0, 10) - .Select(i => Msg($"Plc.M{i:00}", eAxoMessageCategory.Warning, t.AddSeconds(i))) + .Select(i => Msg($"Plc.M{i:00}", eAxoMessageCategory.Error, t.AddSeconds(i))) .ToArray(); var analyzer = new AxoCauseAnalyzer( diff --git a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoIncidentBarPresenterTests.cs b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoIncidentBarPresenterTests.cs index f3058a49f..0220a11c5 100644 --- a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoIncidentBarPresenterTests.cs +++ b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoIncidentBarPresenterTests.cs @@ -34,13 +34,18 @@ private sealed record FakeMsg( string SenderDisplayName) : IRankableMessage; private static (AxoCauseAnalyzer analyzer, Action> setSource, Action setNow) - BuildAnalyzer(TimeSpan? hold = null) + BuildAnalyzer(TimeSpan? hold = null, eAxoMessageCategory? floor = null) { IEnumerable source = Array.Empty(); DateTime now = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); var analyzer = new AxoCauseAnalyzer( () => source, - options: new AxoCauseAnalyzerOptions { HoldDuration = hold ?? TimeSpan.Zero }, + options: new AxoCauseAnalyzerOptions + { + HoldDuration = hold ?? TimeSpan.Zero, + // Default tests below Error need floor lowered explicitly. + CauseSeverityFloor = floor ?? eAxoMessageCategory.Error, + }, nowUtc: () => now); return (analyzer, s => source = s, t => now = t); } @@ -62,7 +67,7 @@ public void Bar_is_hidden_when_active_count_is_zero() public void Bar_is_visible_when_top_cause_exists() { var (analyzer, setSource, _) = BuildAnalyzer(); - setSource(new[] { Msg("Plc.X", eAxoMessageCategory.Warning) }); + setSource(new[] { Msg("Plc.X", eAxoMessageCategory.Error) }); analyzer.Recompute(); var presenter = new AxoIncidentBarPresenter(analyzer); @@ -71,16 +76,19 @@ public void Bar_is_visible_when_top_cause_exists() Assert.NotNull(presenter.CurrentState.TopCause); } + // Below-floor categories still need the bucket mapping for any consumer that + // calls ToSeverityBucket directly (e.g. row rendering of a manually shown alarm). + // Floor lowered to Info so the analyzer surfaces them as causes for the test. [Theory] - [InlineData(eAxoMessageCategory.Critical, IncidentBarSeverity.Danger)] - [InlineData(eAxoMessageCategory.Error, IncidentBarSeverity.Danger)] - [InlineData(eAxoMessageCategory.ProgrammingError, IncidentBarSeverity.Danger)] + [InlineData(eAxoMessageCategory.Critical, IncidentBarSeverity.Critical)] + [InlineData(eAxoMessageCategory.Error, IncidentBarSeverity.Error)] + [InlineData(eAxoMessageCategory.ProgrammingError, IncidentBarSeverity.Error)] [InlineData(eAxoMessageCategory.Warning, IncidentBarSeverity.Warning)] [InlineData(eAxoMessageCategory.Potential, IncidentBarSeverity.Info)] [InlineData(eAxoMessageCategory.Info, IncidentBarSeverity.Info)] public void Severity_maps_category_to_visual_bucket(eAxoMessageCategory cat, IncidentBarSeverity expected) { - var (analyzer, setSource, _) = BuildAnalyzer(); + var (analyzer, setSource, _) = BuildAnalyzer(floor: eAxoMessageCategory.Info); setSource(new[] { Msg("Plc.X", cat) }); analyzer.Recompute(); @@ -97,7 +105,7 @@ public void Severity_maps_category_to_visual_bucket(eAxoMessageCategory cat, Inc [InlineData(eAxoMessageCategory.Info, false)] public void Pulse_only_for_critical_class_when_top_not_acked(eAxoMessageCategory cat, bool expectedPulse) { - var (analyzer, setSource, _) = BuildAnalyzer(); + var (analyzer, setSource, _) = BuildAnalyzer(floor: eAxoMessageCategory.Info); setSource(new[] { Msg("Plc.X", cat) }); analyzer.Recompute(); @@ -128,8 +136,8 @@ public void Additional_count_equals_visible_rows_minus_one() setSource(new[] { Msg("Plc.A", eAxoMessageCategory.Error, t), - Msg("Plc.B", eAxoMessageCategory.Warning, t), - Msg("Plc.C", eAxoMessageCategory.Warning, t), + Msg("Plc.B", eAxoMessageCategory.Error, t), + Msg("Plc.C", eAxoMessageCategory.Error, t), }); analyzer.Recompute(); @@ -143,7 +151,7 @@ public void Additional_count_equals_visible_rows_minus_one() public void Rows_match_analyzer_probable_causes_in_order() { var t = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); - var a = Msg("Plc.A", eAxoMessageCategory.Warning, t); + var a = Msg("Plc.A", eAxoMessageCategory.Error, t); var b = Msg("Plc.B", eAxoMessageCategory.Critical, t); var (analyzer, setSource, _) = BuildAnalyzer(); @@ -176,31 +184,55 @@ public void Ack_pending_marker_persists_until_resolved() } [Theory] - [InlineData(IncidentBarSeverity.Danger, "shadow-glow-danger")] - [InlineData(IncidentBarSeverity.Warning, "shadow-glow-warning")] - [InlineData(IncidentBarSeverity.Info, "shadow-glow-info")] - [InlineData(IncidentBarSeverity.None, "")] + [InlineData(IncidentBarSeverity.Critical, "shadow-glow-danger")] + [InlineData(IncidentBarSeverity.Error, "shadow-glow-danger")] + [InlineData(IncidentBarSeverity.Warning, "shadow-glow-warning")] + [InlineData(IncidentBarSeverity.Info, "shadow-glow-info")] + [InlineData(IncidentBarSeverity.None, "")] public void Glow_class_matches_severity_bucket(IncidentBarSeverity sev, string expected) { Assert.Equal(expected, AxoIncidentBarPresenter.GlowClass(sev)); } [Theory] - [InlineData(IncidentBarSeverity.Danger, "badge badge-danger")] - [InlineData(IncidentBarSeverity.Warning, "badge badge-warning")] - [InlineData(IncidentBarSeverity.Info, "badge badge-primary")] - [InlineData(IncidentBarSeverity.None, "")] + [InlineData(IncidentBarSeverity.Critical, "badge badge-danger")] + [InlineData(IncidentBarSeverity.Error, "badge badge-danger")] + [InlineData(IncidentBarSeverity.Warning, "badge badge-warning")] + [InlineData(IncidentBarSeverity.Info, "badge badge-primary")] + [InlineData(IncidentBarSeverity.None, "")] public void Badge_class_matches_severity_bucket(IncidentBarSeverity sev, string expected) { Assert.Equal(expected, AxoIncidentBarPresenter.BadgeClass(sev)); } + // Background color token must match the glow color token for the same severity + // (e.g. both 'danger' or both 'warning'), so the bar reads as a single chromatic block. + [Theory] + [InlineData(IncidentBarSeverity.Critical, "danger")] + [InlineData(IncidentBarSeverity.Error, "danger")] + [InlineData(IncidentBarSeverity.Warning, "warning")] + [InlineData(IncidentBarSeverity.Info, "info")] + public void Background_uses_same_color_token_as_glow(IncidentBarSeverity sev, string token) + { + var bg = AxoIncidentBarPresenter.BackgroundClass(sev); + var glow = AxoIncidentBarPresenter.GlowClass(sev); + + Assert.Contains(token, bg); + Assert.Contains(token, glow); + } + + [Fact] + public void Background_class_is_empty_for_none_severity() + { + Assert.Equal(string.Empty, AxoIncidentBarPresenter.BackgroundClass(IncidentBarSeverity.None)); + } + [Fact] public void Idle_hysteresis_keeps_bar_visible_after_top_cause_clears() { var t0 = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); var now = t0; - IEnumerable source = new[] { Msg("Plc.X", eAxoMessageCategory.Warning, t0) }; + IEnumerable source = new[] { Msg("Plc.X", eAxoMessageCategory.Error, t0) }; var analyzer = new AxoCauseAnalyzer( () => source, diff --git a/src/styling/src/wwwroot/css/momentum.css b/src/styling/src/wwwroot/css/momentum.css index 356237f5f..1ca14ac2b 100644 --- a/src/styling/src/wwwroot/css/momentum.css +++ b/src/styling/src/wwwroot/css/momentum.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0}}}@layer theme{:root,:host{--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-orange-50:oklch(98% .016 73.684);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-lime-400:oklch(84.1% .238 128.85);--color-green-500:oklch(72.3% .219 149.579);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-700:oklch(50.8% .118 165.612);--color-cyan-50:oklch(98.4% .019 200.873);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-200:oklch(91.7% .08 205.041);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-700:oklch(52% .105 223.128);--color-cyan-900:oklch(39.8% .07 227.392);--color-sky-400:oklch(74.6% .16 232.661);--color-blue-500:oklch(62.3% .214 259.815);--color-purple-500:oklch(62.7% .265 303.9);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-xl:36rem;--container-3xl:48rem;--container-4xl:56rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--animate-bounce:bounce 1s infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base,components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-2{top:calc(var(--spacing) * 2)}.right-2{right:calc(var(--spacing) * 2)}.bottom-0{bottom:calc(var(--spacing) * 0)}.-z-1{z-index:calc(1 * -1)}.z-10{z-index:10}.z-\[600\]{z-index:600}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.\!m-0{margin:calc(var(--spacing) * 0)!important}.m-0{margin:calc(var(--spacing) * 0)}.m-0\!{margin:calc(var(--spacing) * 0)!important}.m-1{margin:calc(var(--spacing) * 1)}.m-2{margin:calc(var(--spacing) * 2)}.m-4{margin:calc(var(--spacing) * 4)}.-mx-2{margin-inline:calc(var(--spacing) * -2)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing) * 1)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-auto{margin-block:auto}.ms-1{margin-inline-start:calc(var(--spacing) * 1)}.ms-2{margin-inline-start:calc(var(--spacing) * 2)}.ms-4{margin-inline-start:calc(var(--spacing) * 4)}.ms-auto{margin-inline-start:auto}.me-1{margin-inline-end:calc(var(--spacing) * 1)}.me-2{margin-inline-end:calc(var(--spacing) * 2)}.me-4{margin-inline-end:calc(var(--spacing) * 4)}.me-6{margin-inline-end:calc(var(--spacing) * 6)}.me-auto{margin-inline-end:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-\[15vh\]{margin-top:15vh}.mt-auto{margin-top:auto}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-0{margin-bottom:calc(var(--spacing) * 0)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-5{margin-left:calc(var(--spacing) * 5)}.ml-auto{margin-left:auto}.line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.flex\!{display:flex!important}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-16{width:calc(var(--spacing) * 16);height:calc(var(--spacing) * 16)}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-12{height:calc(var(--spacing) * 12)}.h-15{height:calc(var(--spacing) * 15)}.h-auto{height:auto}.h-full{height:100%}.max-h-\[50vh\]{max-height:50vh}.max-h-\[70vh\]{max-height:70vh}.min-h-40{min-height:calc(var(--spacing) * 40)}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-15{width:calc(var(--spacing) * 15)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-50{width:calc(var(--spacing) * 50)}.w-75{width:calc(var(--spacing) * 75)}.w-100{width:calc(var(--spacing) * 100)}.w-125{width:calc(var(--spacing) * 125)}.w-\[1px\]{width:1px}.w-auto{width:auto}.w-full{width:100%}.w-md{width:var(--container-md)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-200{max-width:calc(var(--spacing) * 200)}.max-w-none{max-width:none}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-6{min-width:calc(var(--spacing) * 6)}.min-w-20{min-width:calc(var(--spacing) * 20)}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-\[10rem\]{min-width:10rem}.min-w-\[12rem\]{min-width:12rem}.flex-1{flex:1}.flex-\[2\]{flex:2}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow-1,.grow,.grow-1{flex-grow:1}.basis-1\/3{flex-basis:33.3333%}.basis-2\/3{flex-basis:66.6667%}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-bounce{animation:var(--animate-bounce)}.animate-none{animation:none}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-move{cursor:move}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-row\!{flex-direction:row!important}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-center\!{align-items:center!important}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing) * 0)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.self-center{align-self:center}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-visible{overflow-y:visible}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-l-md{border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-r-md{border-top-right-radius:var(--radius-md);border-bottom-right-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-b,.border-b-1{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-solid{--tw-border-style:solid;border-style:solid}.border-blue-500\/50{border-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/50{border-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.border-current{border-color:currentColor}.border-cyan-200{border-color:var(--color-cyan-200)}.border-cyan-400{border-color:var(--color-cyan-400)}.border-green-500\/50{border-color:#00c75880}@supports (color:color-mix(in lab, red, red)){.border-green-500\/50{border-color:color-mix(in oklab, var(--color-green-500) 50%, transparent)}}.border-orange-200{border-color:var(--color-orange-200)}.border-orange-400\/50{border-color:#ff8b1a80}@supports (color:color-mix(in lab, red, red)){.border-orange-400\/50{border-color:color-mix(in oklab, var(--color-orange-400) 50%, transparent)}}.border-red-500\/50{border-color:#fb2c3680}@supports (color:color-mix(in lab, red, red)){.border-red-500\/50{border-color:color-mix(in oklab, var(--color-red-500) 50%, transparent)}}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-slate-400\/35{border-color:#90a1b959}@supports (color:color-mix(in lab, red, red)){.border-slate-400\/35{border-color:color-mix(in oklab, var(--color-slate-400) 35%, transparent)}}.border-slate-500\/40{border-color:#62748e66}@supports (color:color-mix(in lab, red, red)){.border-slate-500\/40{border-color:color-mix(in oklab, var(--color-slate-500) 40%, transparent)}}.border-yellow-500\/50{border-color:#edb20080}@supports (color:color-mix(in lab, red, red)){.border-yellow-500\/50{border-color:color-mix(in oklab, var(--color-yellow-500) 50%, transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-blue-500{background-color:var(--color-blue-500)}.bg-current{background-color:currentColor}.bg-cyan-50{background-color:var(--color-cyan-50)}.bg-cyan-100\/40{background-color:#cefafe66}@supports (color:color-mix(in lab, red, red)){.bg-cyan-100\/40{background-color:color-mix(in oklab, var(--color-cyan-100) 40%, transparent)}}.bg-cyan-500{background-color:var(--color-cyan-500)}.bg-gray-700{background-color:var(--color-gray-700)}.bg-green-500{background-color:var(--color-green-500)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-red-500{background-color:var(--color-red-500)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400\/20{background-color:#90a1b933}@supports (color:color-mix(in lab, red, red)){.bg-slate-400\/20{background-color:color-mix(in oklab, var(--color-slate-400) 20%, transparent)}}.bg-slate-500{background-color:var(--color-slate-500)}.bg-slate-700{background-color:var(--color-slate-700)}.bg-slate-700\/40{background-color:#31415866}@supports (color:color-mix(in lab, red, red)){.bg-slate-700\/40{background-color:color-mix(in oklab, var(--color-slate-700) 40%, transparent)}}.bg-slate-800\/60{background-color:#1d293d99}@supports (color:color-mix(in lab, red, red)){.bg-slate-800\/60{background-color:color-mix(in oklab, var(--color-slate-800) 60%, transparent)}}.bg-slate-900\/35{background-color:#0f172b59}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/35{background-color:color-mix(in oklab, var(--color-slate-900) 35%, transparent)}}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.bg-white\/80{background-color:#fffc}@supports (color:color-mix(in lab, red, red)){.bg-white\/80{background-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-linear-to-br{--tw-gradient-position:to bottom right}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-br{--tw-gradient-position:to bottom right in oklab}}.bg-linear-to-br{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-linear-to-r{--tw-gradient-position:to right}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-r{--tw-gradient-position:to right in oklab}}.bg-linear-to-r{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-cyan-50{--tw-gradient-from:var(--color-cyan-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-emerald-50{--tw-gradient-from:var(--color-emerald-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-green-500{--tw-gradient-from:var(--color-green-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-red-500{--tw-gradient-from:var(--color-red-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-yellow-400{--tw-gradient-from:var(--color-yellow-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-0\%{--tw-gradient-from-position:0%}.via-amber-400{--tw-gradient-via:var(--color-amber-400);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-cyan-500{--tw-gradient-via:var(--color-cyan-500);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-lime-400{--tw-gradient-via:var(--color-lime-400);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-purple-500{--tw-gradient-via:var(--color-purple-500);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-sky-400{--tw-gradient-via:var(--color-sky-400);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-yellow-400{--tw-gradient-via:var(--color-yellow-400);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-blue-500{--tw-gradient-to:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-green-500{--tw-gradient-to:var(--color-green-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-white{--tw-gradient-to:var(--color-white);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-50\%{--tw-gradient-to-position:50%}.\!p-0{padding:calc(var(--spacing) * 0)!important}.p-0{padding:calc(var(--spacing) * 0)}.p-0\!{padding:calc(var(--spacing) * 0)!important}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-10{padding:calc(var(--spacing) * 10)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\!{padding-inline:calc(var(--spacing) * 1)!important}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\!{padding-inline:calc(var(--spacing) * 2)!important}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\!{padding-block:calc(var(--spacing) * 2)!important}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.ps-3{padding-inline-start:calc(var(--spacing) * 3)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-2{padding-left:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-start{text-align:start}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.8rem\]{font-size:.8rem}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.85rem\]{font-size:.85rem}.text-\[0\.95rem\]{font-size:.95rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-nowrap{text-wrap:nowrap}.whitespace-nowrap{white-space:nowrap}.text-amber-400{color:var(--color-amber-400)}.text-amber-600\/80{color:#dd7400cc}@supports (color:color-mix(in lab, red, red)){.text-amber-600\/80{color:color-mix(in oklab, var(--color-amber-600) 80%, transparent)}}.text-amber-700{color:var(--color-amber-700)}.text-blue-500{color:var(--color-blue-500)}.text-cyan-700{color:var(--color-cyan-700)}.text-cyan-900{color:var(--color-cyan-900)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-700{color:var(--color-emerald-700)}.text-gray-100{color:var(--color-gray-100)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-inherit{color:inherit}.text-inherit\!{color:inherit!important}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-slate-100\/95{color:#f1f5f9f2}@supports (color:color-mix(in lab, red, red)){.text-slate-100\/95{color:color-mix(in oklab, var(--color-slate-100) 95%, transparent)}}.text-slate-200{color:var(--color-slate-200)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-400\/90{color:#90a1b9e6}@supports (color:color-mix(in lab, red, red)){.text-slate-400\/90{color:color-mix(in oklab, var(--color-slate-400) 90%, transparent)}}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-slate-900\/70{color:#0f172bb3}@supports (color:color-mix(in lab, red, red)){.text-slate-900\/70{color:color-mix(in oklab, var(--color-slate-900) 70%, transparent)}}.text-slate-900\/85{color:#0f172bd9}@supports (color:color-mix(in lab, red, red)){.text-slate-900\/85{color:color-mix(in oklab, var(--color-slate-900) 85%, transparent)}}.text-slate-900\/90{color:#0f172be6}@supports (color:color-mix(in lab, red, red)){.text-slate-900\/90{color:color-mix(in oklab, var(--color-slate-900) 90%, transparent)}}.text-white{color:var(--color-white)}.text-yellow-500{color:var(--color-yellow-500)}.uppercase{text-transform:uppercase}.placeholder-slate-400::placeholder{color:var(--color-slate-400)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(34\,197\,94\,0\.6\)\]{--tw-shadow:0 0 8px var(--tw-shadow-color,#22c55e99);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-blue-500\/50{--tw-shadow-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-green-500\/50{--tw-shadow-color:#00c75880}@supports (color:color-mix(in lab, red, red)){.shadow-green-500\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-green-500) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-orange-400\/40{--tw-shadow-color:#ff8b1a66}@supports (color:color-mix(in lab, red, red)){.shadow-orange-400\/40{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-orange-400) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-500\/50{--tw-shadow-color:#fb2c3680}@supports (color:color-mix(in lab, red, red)){.shadow-red-500\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-500) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-yellow-500\/50{--tw-shadow-color:#edb20080}@supports (color:color-mix(in lab, red, red)){.shadow-yellow-500\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-yellow-500) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.blur-\[1px\]{--tw-blur:blur(1px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-400{--tw-duration:.4s;transition-duration:.4s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[assembly\:InternalsVisibleTo\(\"axopen\.inspectors_tests\"\)\]{assembly:InternalsVisibleTo("axopen.inspectors tests")}.\[assembly\:InternalsVisibleTo\(\"axopen_core_tests\"\)\]{assembly:InternalsVisibleTo("axopen core tests")}.\[assembly\:InternalsVisibleTo\(\"axopen_core_tests_L1\"\)\]{assembly:InternalsVisibleTo("axopen core tests L1")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsabbrobotics_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsabbrobotics tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsballuffidentification_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsballuffidentification tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentscognexvision_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentscognexvision tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsdesouttertightening_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsdesouttertightening tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsdrives_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsdrives tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsfestodrives_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsfestodrives tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentskeyencevision_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentskeyencevision tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentskukarobotics_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentskukarobotics tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsmitsubishirobotics_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsmitsubishirobotics tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsrexrothdrives_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsrexrothdrives tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsrexrothpress_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsrexrothpress tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsrobotics_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsrobotics tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentssiemidentification_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentssiemidentification tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsurrobotics_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsurrobotics tests")}.\[assembly\:InternalsVisibleTo\(\"axopenio_tests\"\)\]{assembly:InternalsVisibleTo("axopenio tests")}.\[assembly\:InternalsVisibleTo\(\"components\.dukane\.welders_tests\"\)\]{assembly:InternalsVisibleTo("components.dukane.welders tests")}.\[assembly\:InternalsVisibleTo\(\"components\.rexroth\.tightening_tests\"\)\]{assembly:InternalsVisibleTo("components.rexroth.tightening tests")}.\[assembly\:InternalsVisibleTo\(\"components\.siem\.communication_tests\"\)\]{assembly:InternalsVisibleTo("components.siem.communication tests")}.\[assembly\:InternalsVisibleTo\(\"components\.zebra\.vision_tests\"\)\]{assembly:InternalsVisibleTo("components.zebra.vision tests")}.\[assembly\:InternalsVisibleTo\(\"elementscomponents_tests\"\)\]{assembly:InternalsVisibleTo("elementscomponents tests")}.\[assembly\:InternalsVisibleTo\(\"librarytemplate_tests\"\)\]{assembly:InternalsVisibleTo("librarytemplate tests")}.\[assembly\:InternalsVisibleTo\(\"pneumaticcomponents_tests\"\)\]{assembly:InternalsVisibleTo("pneumaticcomponents tests")}@media (hover:hover){.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.hover\:-translate-y-0\.5:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.hover\:border-slate-300:hover{border-color:var(--color-slate-300)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-600:hover{background-color:var(--color-slate-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-cyan-500:focus{border-color:var(--color-cyan-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-cyan-200:focus{--tw-ring-color:var(--color-cyan-200)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}@media (min-width:40rem){.sm\:inline{display:inline}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media (min-width:48rem){.md\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-rows-2{grid-template-rows:repeat(2,minmax(0,1fr))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}@media (min-width:80rem){.xl\:col-span-1{grid-column:span 1/span 1}.xl\:col-span-2{grid-column:span 2/span 2}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0}}}@layer theme{:root,:host{--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-orange-50:oklch(98% .016 73.684);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-lime-400:oklch(84.1% .238 128.85);--color-green-500:oklch(72.3% .219 149.579);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-700:oklch(50.8% .118 165.612);--color-cyan-50:oklch(98.4% .019 200.873);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-200:oklch(91.7% .08 205.041);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-700:oklch(52% .105 223.128);--color-cyan-900:oklch(39.8% .07 227.392);--color-sky-400:oklch(74.6% .16 232.661);--color-blue-500:oklch(62.3% .214 259.815);--color-purple-500:oklch(62.7% .265 303.9);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-xl:36rem;--container-3xl:48rem;--container-4xl:56rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--animate-bounce:bounce 1s infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base,components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-2{top:calc(var(--spacing) * 2)}.right-2{right:calc(var(--spacing) * 2)}.bottom-0{bottom:calc(var(--spacing) * 0)}.-z-1{z-index:calc(1 * -1)}.z-10{z-index:10}.z-\[600\]{z-index:600}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.\!m-0{margin:calc(var(--spacing) * 0)!important}.m-0{margin:calc(var(--spacing) * 0)}.m-0\!{margin:calc(var(--spacing) * 0)!important}.m-1{margin:calc(var(--spacing) * 1)}.m-2{margin:calc(var(--spacing) * 2)}.m-4{margin:calc(var(--spacing) * 4)}.-mx-2{margin-inline:calc(var(--spacing) * -2)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing) * 1)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-auto{margin-block:auto}.ms-1{margin-inline-start:calc(var(--spacing) * 1)}.ms-2{margin-inline-start:calc(var(--spacing) * 2)}.ms-4{margin-inline-start:calc(var(--spacing) * 4)}.ms-auto{margin-inline-start:auto}.me-1{margin-inline-end:calc(var(--spacing) * 1)}.me-2{margin-inline-end:calc(var(--spacing) * 2)}.me-4{margin-inline-end:calc(var(--spacing) * 4)}.me-6{margin-inline-end:calc(var(--spacing) * 6)}.me-auto{margin-inline-end:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-\[15vh\]{margin-top:15vh}.mt-auto{margin-top:auto}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-0{margin-bottom:calc(var(--spacing) * 0)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-5{margin-left:calc(var(--spacing) * 5)}.ml-auto{margin-left:auto}.line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.flex\!{display:flex!important}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-16{width:calc(var(--spacing) * 16);height:calc(var(--spacing) * 16)}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-15{height:calc(var(--spacing) * 15)}.h-auto{height:auto}.h-full{height:100%}.max-h-\[50vh\]{max-height:50vh}.max-h-\[70vh\]{max-height:70vh}.min-h-40{min-height:calc(var(--spacing) * 40)}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-15{width:calc(var(--spacing) * 15)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-50{width:calc(var(--spacing) * 50)}.w-75{width:calc(var(--spacing) * 75)}.w-100{width:calc(var(--spacing) * 100)}.w-125{width:calc(var(--spacing) * 125)}.w-\[1px\]{width:1px}.w-auto{width:auto}.w-full{width:100%}.w-md{width:var(--container-md)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-200{max-width:calc(var(--spacing) * 200)}.max-w-none{max-width:none}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-6{min-width:calc(var(--spacing) * 6)}.min-w-20{min-width:calc(var(--spacing) * 20)}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-\[10rem\]{min-width:10rem}.min-w-\[12rem\]{min-width:12rem}.flex-1{flex:1}.flex-\[2\]{flex:2}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow-1,.grow,.grow-1{flex-grow:1}.basis-1\/3{flex-basis:33.3333%}.basis-2\/3{flex-basis:66.6667%}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-bounce{animation:var(--animate-bounce)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-move{cursor:move}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-row\!{flex-direction:row!important}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-center\!{align-items:center!important}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing) * 0)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.self-center{align-self:center}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-visible{overflow-y:visible}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-l-md{border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-r-md{border-top-right-radius:var(--radius-md);border-bottom-right-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-b,.border-b-1{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-solid{--tw-border-style:solid;border-style:solid}.border-blue-500\/50{border-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/50{border-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.border-current{border-color:currentColor}.border-cyan-200{border-color:var(--color-cyan-200)}.border-cyan-400{border-color:var(--color-cyan-400)}.border-green-500\/50{border-color:#00c75880}@supports (color:color-mix(in lab, red, red)){.border-green-500\/50{border-color:color-mix(in oklab, var(--color-green-500) 50%, transparent)}}.border-orange-200{border-color:var(--color-orange-200)}.border-orange-400\/50{border-color:#ff8b1a80}@supports (color:color-mix(in lab, red, red)){.border-orange-400\/50{border-color:color-mix(in oklab, var(--color-orange-400) 50%, transparent)}}.border-red-500\/50{border-color:#fb2c3680}@supports (color:color-mix(in lab, red, red)){.border-red-500\/50{border-color:color-mix(in oklab, var(--color-red-500) 50%, transparent)}}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-slate-400\/35{border-color:#90a1b959}@supports (color:color-mix(in lab, red, red)){.border-slate-400\/35{border-color:color-mix(in oklab, var(--color-slate-400) 35%, transparent)}}.border-slate-500\/40{border-color:#62748e66}@supports (color:color-mix(in lab, red, red)){.border-slate-500\/40{border-color:color-mix(in oklab, var(--color-slate-500) 40%, transparent)}}.border-yellow-500\/50{border-color:#edb20080}@supports (color:color-mix(in lab, red, red)){.border-yellow-500\/50{border-color:color-mix(in oklab, var(--color-yellow-500) 50%, transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-blue-500{background-color:var(--color-blue-500)}.bg-current{background-color:currentColor}.bg-cyan-50{background-color:var(--color-cyan-50)}.bg-cyan-100\/40{background-color:#cefafe66}@supports (color:color-mix(in lab, red, red)){.bg-cyan-100\/40{background-color:color-mix(in oklab, var(--color-cyan-100) 40%, transparent)}}.bg-cyan-500{background-color:var(--color-cyan-500)}.bg-gray-700{background-color:var(--color-gray-700)}.bg-green-500{background-color:var(--color-green-500)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-red-500{background-color:var(--color-red-500)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400\/20{background-color:#90a1b933}@supports (color:color-mix(in lab, red, red)){.bg-slate-400\/20{background-color:color-mix(in oklab, var(--color-slate-400) 20%, transparent)}}.bg-slate-500{background-color:var(--color-slate-500)}.bg-slate-700{background-color:var(--color-slate-700)}.bg-slate-700\/40{background-color:#31415866}@supports (color:color-mix(in lab, red, red)){.bg-slate-700\/40{background-color:color-mix(in oklab, var(--color-slate-700) 40%, transparent)}}.bg-slate-800\/60{background-color:#1d293d99}@supports (color:color-mix(in lab, red, red)){.bg-slate-800\/60{background-color:color-mix(in oklab, var(--color-slate-800) 60%, transparent)}}.bg-slate-900\/35{background-color:#0f172b59}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/35{background-color:color-mix(in oklab, var(--color-slate-900) 35%, transparent)}}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.bg-white\/80{background-color:#fffc}@supports (color:color-mix(in lab, red, red)){.bg-white\/80{background-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-linear-to-br{--tw-gradient-position:to bottom right}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-br{--tw-gradient-position:to bottom right in oklab}}.bg-linear-to-br{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-linear-to-r{--tw-gradient-position:to right}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-r{--tw-gradient-position:to right in oklab}}.bg-linear-to-r{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-cyan-50{--tw-gradient-from:var(--color-cyan-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-emerald-50{--tw-gradient-from:var(--color-emerald-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-green-500{--tw-gradient-from:var(--color-green-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-red-500{--tw-gradient-from:var(--color-red-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-yellow-400{--tw-gradient-from:var(--color-yellow-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-0\%{--tw-gradient-from-position:0%}.via-amber-400{--tw-gradient-via:var(--color-amber-400);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-cyan-500{--tw-gradient-via:var(--color-cyan-500);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-lime-400{--tw-gradient-via:var(--color-lime-400);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-purple-500{--tw-gradient-via:var(--color-purple-500);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-sky-400{--tw-gradient-via:var(--color-sky-400);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-yellow-400{--tw-gradient-via:var(--color-yellow-400);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-blue-500{--tw-gradient-to:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-green-500{--tw-gradient-to:var(--color-green-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-white{--tw-gradient-to:var(--color-white);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-50\%{--tw-gradient-to-position:50%}.\!p-0{padding:calc(var(--spacing) * 0)!important}.p-0{padding:calc(var(--spacing) * 0)}.p-0\!{padding:calc(var(--spacing) * 0)!important}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-10{padding:calc(var(--spacing) * 10)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\!{padding-inline:calc(var(--spacing) * 1)!important}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\!{padding-inline:calc(var(--spacing) * 2)!important}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\!{padding-block:calc(var(--spacing) * 1)!important}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\!{padding-block:calc(var(--spacing) * 2)!important}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.ps-3{padding-inline-start:calc(var(--spacing) * 3)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pl-2{padding-left:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-start{text-align:start}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.8rem\]{font-size:.8rem}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.85rem\]{font-size:.85rem}.text-\[0\.95rem\]{font-size:.95rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.text-nowrap{text-wrap:nowrap}.wrap-anywhere{overflow-wrap:anywhere}.whitespace-nowrap{white-space:nowrap}.text-amber-400{color:var(--color-amber-400)}.text-amber-600\/80{color:#dd7400cc}@supports (color:color-mix(in lab, red, red)){.text-amber-600\/80{color:color-mix(in oklab, var(--color-amber-600) 80%, transparent)}}.text-amber-700{color:var(--color-amber-700)}.text-blue-500{color:var(--color-blue-500)}.text-cyan-700{color:var(--color-cyan-700)}.text-cyan-900{color:var(--color-cyan-900)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-700{color:var(--color-emerald-700)}.text-gray-100{color:var(--color-gray-100)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-inherit{color:inherit}.text-inherit\!{color:inherit!important}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-slate-100\/95{color:#f1f5f9f2}@supports (color:color-mix(in lab, red, red)){.text-slate-100\/95{color:color-mix(in oklab, var(--color-slate-100) 95%, transparent)}}.text-slate-200{color:var(--color-slate-200)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-400\/90{color:#90a1b9e6}@supports (color:color-mix(in lab, red, red)){.text-slate-400\/90{color:color-mix(in oklab, var(--color-slate-400) 90%, transparent)}}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-slate-900\/70{color:#0f172bb3}@supports (color:color-mix(in lab, red, red)){.text-slate-900\/70{color:color-mix(in oklab, var(--color-slate-900) 70%, transparent)}}.text-slate-900\/85{color:#0f172bd9}@supports (color:color-mix(in lab, red, red)){.text-slate-900\/85{color:color-mix(in oklab, var(--color-slate-900) 85%, transparent)}}.text-slate-900\/90{color:#0f172be6}@supports (color:color-mix(in lab, red, red)){.text-slate-900\/90{color:color-mix(in oklab, var(--color-slate-900) 90%, transparent)}}.text-white{color:var(--color-white)}.text-yellow-500{color:var(--color-yellow-500)}.uppercase{text-transform:uppercase}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.placeholder-slate-400::placeholder{color:var(--color-slate-400)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(34\,197\,94\,0\.6\)\]{--tw-shadow:0 0 8px var(--tw-shadow-color,#22c55e99);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-blue-500\/50{--tw-shadow-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-green-500\/50{--tw-shadow-color:#00c75880}@supports (color:color-mix(in lab, red, red)){.shadow-green-500\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-green-500) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-orange-400\/40{--tw-shadow-color:#ff8b1a66}@supports (color:color-mix(in lab, red, red)){.shadow-orange-400\/40{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-orange-400) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-500\/50{--tw-shadow-color:#fb2c3680}@supports (color:color-mix(in lab, red, red)){.shadow-red-500\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-500) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-yellow-500\/50{--tw-shadow-color:#edb20080}@supports (color:color-mix(in lab, red, red)){.shadow-yellow-500\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-yellow-500) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-400{--tw-duration:.4s;transition-duration:.4s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[assembly\:InternalsVisibleTo\(\"axopen\.inspectors_tests\"\)\]{assembly:InternalsVisibleTo("axopen.inspectors tests")}.\[assembly\:InternalsVisibleTo\(\"axopen_core_tests\"\)\]{assembly:InternalsVisibleTo("axopen core tests")}.\[assembly\:InternalsVisibleTo\(\"axopen_core_tests_L1\"\)\]{assembly:InternalsVisibleTo("axopen core tests L1")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsabbrobotics_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsabbrobotics tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsballuffidentification_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsballuffidentification tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentscognexvision_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentscognexvision tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsdesouttertightening_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsdesouttertightening tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsdrives_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsdrives tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsfestodrives_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsfestodrives tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentskeyencevision_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentskeyencevision tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentskukarobotics_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentskukarobotics tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsmitsubishirobotics_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsmitsubishirobotics tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsrexrothdrives_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsrexrothdrives tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsrexrothpress_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsrexrothpress tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsrobotics_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsrobotics tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentssiemidentification_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentssiemidentification tests")}.\[assembly\:InternalsVisibleTo\(\"axopencomponentsurrobotics_tests\"\)\]{assembly:InternalsVisibleTo("axopencomponentsurrobotics tests")}.\[assembly\:InternalsVisibleTo\(\"axopenio_tests\"\)\]{assembly:InternalsVisibleTo("axopenio tests")}.\[assembly\:InternalsVisibleTo\(\"components\.dukane\.welders_tests\"\)\]{assembly:InternalsVisibleTo("components.dukane.welders tests")}.\[assembly\:InternalsVisibleTo\(\"components\.rexroth\.tightening_tests\"\)\]{assembly:InternalsVisibleTo("components.rexroth.tightening tests")}.\[assembly\:InternalsVisibleTo\(\"components\.siem\.communication_tests\"\)\]{assembly:InternalsVisibleTo("components.siem.communication tests")}.\[assembly\:InternalsVisibleTo\(\"components\.zebra\.vision_tests\"\)\]{assembly:InternalsVisibleTo("components.zebra.vision tests")}.\[assembly\:InternalsVisibleTo\(\"elementscomponents_tests\"\)\]{assembly:InternalsVisibleTo("elementscomponents tests")}.\[assembly\:InternalsVisibleTo\(\"librarytemplate_tests\"\)\]{assembly:InternalsVisibleTo("librarytemplate tests")}.\[assembly\:InternalsVisibleTo\(\"pneumaticcomponents_tests\"\)\]{assembly:InternalsVisibleTo("pneumaticcomponents tests")}@media (hover:hover){.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.hover\:-translate-y-0\.5:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.hover\:border-slate-300:hover{border-color:var(--color-slate-300)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-600:hover{background-color:var(--color-slate-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-cyan-500:focus{border-color:var(--color-cyan-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-cyan-200:focus{--tw-ring-color:var(--color-cyan-200)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}@media (min-width:40rem){.sm\:inline{display:inline}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media (min-width:48rem){.md\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-rows-2{grid-template-rows:repeat(2,minmax(0,1fr))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}@media (min-width:80rem){.xl\:col-span-1{grid-column:span 1/span 1}.xl\:col-span-2{grid-column:span 2/span 2}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}} \ No newline at end of file From 47b41fd0336f3358916b331ebaff58c886de99ca Mon Sep 17 00:00:00 2001 From: Peter Kurhajec <61538034+PTKu@users.noreply.github.com> Date: Wed, 27 May 2026 12:43:09 +0200 Subject: [PATCH 3/4] feat: Implement AxoIncidentBar for incident analysis and visualization --- .../AxoMessenger/Static/AxoCauseAnalyzer.cs | 24 ++- .../Messaging/AxoCauseAnalyzerTests.cs | 18 +- .../Pages/core/AxoIncidentBar.razor | 187 ++++++++++++++++++ .../Services/Search/ShowcasePageRegistry.cs | 19 ++ .../showcase.blazor/Shared/NavMenu.razor | 1 + src/showcase/app/src/ShowcaseContext.st | 4 + .../AXOpen.Messaging/AxoIncidentBarExample.st | 157 +++++++++++++++ 7 files changed, 398 insertions(+), 12 deletions(-) create mode 100644 src/showcase/app/ix-blazor/showcase.blazor/Pages/core/AxoIncidentBar.razor create mode 100644 src/showcase/app/src/core/AXOpen.Messaging/AxoIncidentBarExample.st diff --git a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs index 5cbd92b8a..fe4d64d5b 100644 --- a/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs +++ b/src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs @@ -132,11 +132,25 @@ private void RaiseChangedIfTopFlipped(string? previousTopSymbol) } } - // Other is descendant of parent if its symbol starts with "parent.". - private static bool IsDescendant(string otherSymbol, string parentSymbol) => - otherSymbol.Length > parentSymbol.Length + 1 && - otherSymbol.StartsWith(parentSymbol, StringComparison.Ordinal) && - otherSymbol[parentSymbol.Length] == '.'; + // Topology check: messengers are leaves under their CONTAINER component, so a + // parent component's messenger and a child component's messenger are SIBLINGS in + // the messenger flat list. We strip the last segment of each Symbol (the + // messenger's own name) to recover the container path, then check if one + // container is an ancestor of the other. + private static string ContainerSymbol(string s) + { + var i = s.LastIndexOf('.'); + return i > 0 ? s.Substring(0, i) : string.Empty; + } + + private static bool IsDescendant(string otherSymbol, string parentSymbol) + { + var pc = ContainerSymbol(parentSymbol); + var oc = ContainerSymbol(otherSymbol); + return oc.Length > pc.Length + 1 && + oc.StartsWith(pc, StringComparison.Ordinal) && + oc[pc.Length] == '.'; + } private static bool IsActive(IRankableMessage m) => m.State == eAxoMessengerState.ActiveAcknowledgeRequired || diff --git a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs index be834ed2e..c21955173 100644 --- a/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs +++ b/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs @@ -285,21 +285,25 @@ public void Acked_active_is_listed_but_scored_lower_than_equivalent_unacked() Assert.True(analyzer.ProbableCauses[0].Score > analyzer.ProbableCauses[1].Score); } + // Realistic twin-tree shape: messengers are leaves under component containers. + // Station and Drive are sibling messengers UNDER the Station component, + // so the heuristic must look at each messenger's CONTAINER (strip last segment), + // not at the messenger's own Symbol. [Fact] - public void Symbol_prefix_owner_gets_downstream_count() + public void Topology_uses_container_prefix_not_messenger_symbol_prefix() { var t = new DateTime(2026, 5, 26, 12, 0, 0, DateTimeKind.Utc); - var station = Msg("Plc.Station", eAxoMessageCategory.Error, t); - var drive = Msg("Plc.Station.Drive", eAxoMessageCategory.Error, t); - var encoder = Msg("Plc.Station.Drive.Enc", eAxoMessageCategory.Error, t); - var unrelated = Msg("Plc.Conveyor", eAxoMessageCategory.Error, t); + var station = Msg("Plc.Station.station_alarm", eAxoMessageCategory.Error, t); + var drive = Msg("Plc.Station.Drive.drive_alarm", eAxoMessageCategory.Error, t); + var encoder = Msg("Plc.Station.Drive.Enc.encoder_alarm", eAxoMessageCategory.Error, t); + var unrelated = Msg("Plc.Conveyor.belt_alarm", eAxoMessageCategory.Error, t); var analyzer = new AxoCauseAnalyzer(() => new[] { station, drive, encoder, unrelated }); analyzer.Recompute(); var byMsg = analyzer.ProbableCauses.ToDictionary(c => c.Message); - Assert.Equal(2, byMsg[station].DownstreamCount); - Assert.Equal(1, byMsg[drive].DownstreamCount); + Assert.Equal(2, byMsg[station].DownstreamCount); // owns drive + encoder + Assert.Equal(1, byMsg[drive].DownstreamCount); // owns encoder Assert.Equal(0, byMsg[encoder].DownstreamCount); Assert.Equal(0, byMsg[unrelated].DownstreamCount); } diff --git a/src/showcase/app/ix-blazor/showcase.blazor/Pages/core/AxoIncidentBar.razor b/src/showcase/app/ix-blazor/showcase.blazor/Pages/core/AxoIncidentBar.razor new file mode 100644 index 000000000..7f451f2d5 --- /dev/null +++ b/src/showcase/app/ix-blazor/showcase.blazor/Pages/core/AxoIncidentBar.razor @@ -0,0 +1,187 @@ +@page "/core/AxoIncidentBar" +@using AXOpen.Core +@using AXOpen.Messaging.Static +@using Operon.Components.Tab +@using showcase.Services +@inherits RenderableComponentBase +Core — AxoIncidentBar + + +
+ + +
+

AXOpen.Core / AXOpen.Core.Blazor

+

Probable Cause & Incident Bar

+

+ AxoCauseAnalyzer ranks active AxoMessenger instances by severity, time-of-rise (burst root), + twin-tree topology (a parent component "owns" downstream alarms), and acknowledgement state. + AxoIncidentBarView renders the top cause as a persistent, severity-colored bar above the layout. + Only Error-and-above messages enter the cause ranking. +

+
+ +
+ + +
+
+

Incident Bar Documentation

+

Toggle the condition flags below to fire alarms across a nested topology and watch the bar surface the top probable cause.

+ +
+ + +
+
+

The bar

+

Mounted with a provider scoped to the showcase topology below. Default severity floor is Error.

+ @if (_provider is not null) + { + + } +
+ +
+

Topology controls

+

+ Station (Error) → Drive (Critical) → Encoder (Error). + Station (Error) → Conveyor (Error) → Sensor (Error). + Trigger Station alone to see the top-of-tree win as cause; trigger only a leaf to see it surface; trigger several to see DownstreamCount push the parent to the top. +

+ +
+
+
+ + +
+
+

Nested twin tree

+

+ TopologyDeclaration + StationDeclaration + DriveDeclaration +

+ @if (_snippetTopology is { IsError: false }) + { + + } + else if (_loadingCode) {
Loading...
} + else {

Unable to load snippet

} +
+ +
+

Conditional activation

+

+ StationActivate region +

+ @if (_snippetActivate is { IsError: false }) + { + + } + else if (_loadingCode) {
Loading...
} + else {

Unable to load snippet

} +
+
+
+ + +
+

The analyzer scores each Error-or-above active messenger with:

+
Score = 0.40 * severity_weight(Category)
+      + 0.30 * (is_burst_root ? 1 : 0)
+      + 0.20 * log10(1 + DownstreamCount)
+      + 0.10 * (is_acknowledged ? 0 : 1)
+      - 0.02 * minutes_since_risen
+
    +
  • severity_weight: Critical=1.0, Error=0.9, ProgrammingError=0.85, Warning=0.6, Potential=0.4, Info=0.1
  • +
  • burst_root: earliest Risen in the last 8 s window — likely root of a cascade
  • +
  • DownstreamCount: count of active messengers whose container is a descendant of this one's container
  • +
  • Anti-strobe: published top cause is held for 2 s when the source momentarily reads empty (PLC-cycle flicker)
  • +
  • Idle hysteresis: bar stays visible 2 s after the last cause clears
  • +
+

Engineers can override defaults via AxoCauseAnalyzerOptions (BurstWindow, HoldDuration, IdleHysteresis, TopN, CauseSeverityFloor).

+
+
+
+
+
+
+
+
+ +@code { + [Inject] private CodeSnippetProvider CodeProvider { get; set; } = default!; + private SourceViewerModal _modal = default!; + private Task OpenModalAsync(string path) => _modal.OpenAsync(path); + + private AxoMessageProvider? _provider; + + private CodeSnippet? _snippetTopology; + private CodeSnippet? _snippetActivate; + private bool _loadingCode = true; + + private readonly string _plcExamplePath = "src/showcase/app/src/core/AXOpen.Messaging/AxoIncidentBarExample.st"; + private readonly string _libAnalyzerPath = "src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs"; + private readonly string _libPresenterPath = "src/core/src/AXOpen.Core/AxoMessenger/Static/AxoIncidentBarPresenter.cs"; + private readonly string _libAdapterPath = "src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessengerRankableAdapter.cs"; + private readonly string _libViewPath = "src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/AxoIncidentBarView.razor"; + + protected override void OnInitialized() + { + _provider = AxoMessageProvider.Create(new[] { (AXSharp.Connector.ITwinObject)Entry.Plc.Ctx.AxoIncidentBar }); + } + + protected override async Task OnInitializedAsync() + { + try + { + var topologyTask = CodeProvider.GetTaggedRegionAsync(_plcExamplePath, "TopologyDeclaration"); + var activateTask = CodeProvider.GetTaggedRegionAsync(_plcExamplePath, "StationActivate"); + await Task.WhenAll(topologyTask, activateTask); + _snippetTopology = topologyTask.Result; + _snippetActivate = activateTask.Result; + } + finally + { + _loadingCode = false; + } + } + + public override void ConfigurePolling() + { + // Bar drives its own polling via the provider; we also poll the operator controls. + _provider?.InitializeLightUpdate(this.StartPolling); + } +} diff --git a/src/showcase/app/ix-blazor/showcase.blazor/Services/Search/ShowcasePageRegistry.cs b/src/showcase/app/ix-blazor/showcase.blazor/Services/Search/ShowcasePageRegistry.cs index be627433b..a8e4dbe93 100644 --- a/src/showcase/app/ix-blazor/showcase.blazor/Services/Search/ShowcasePageRegistry.cs +++ b/src/showcase/app/ix-blazor/showcase.blazor/Services/Search/ShowcasePageRegistry.cs @@ -101,6 +101,25 @@ public static List GetAllPages() => ] }, new() + { + Route = "/core/AxoIncidentBar", + PageTitle = "AxoIncidentBar", + LibraryNamespace = "AXOpen.Core", + Category = "Core", + Description = "AxoCauseAnalyzer ranks active Error+ messengers by severity, burst-root, topology, and ack state. AxoIncidentBarView renders the top probable cause as a persistent severity-colored bar.", + Icon = "bell-alert", + Tags = ["incident", "alarm", "probable cause", "root cause", "cause analyzer", "diagnostics", "topology", "burst", "operator", "bar"], + SourceFilePaths = [ + "src/showcase/app/src/core/AXOpen.Messaging/AxoIncidentBarExample.st", + "src/core/src/AXOpen.Core/AxoMessenger/Static/AxoCauseAnalyzer.cs", + "src/core/src/AXOpen.Core/AxoMessenger/Static/AxoIncidentBarPresenter.cs", + "src/core/src/AXOpen.Core/AxoMessenger/Static/AxoMessengerRankableAdapter.cs", + "src/core/src/AXOpen.Core/AxoMessenger/Static/IRankableMessage.cs", + "src/core/src/AXOpen.Core.Blazor/AxoMessenger/Static/AxoIncidentBarView.razor", + "src/core/docs/CHANGELOG.md", + ] + }, + new() { Route = "/core/AxoLogger", PageTitle = "AxoLogger", diff --git a/src/showcase/app/ix-blazor/showcase.blazor/Shared/NavMenu.razor b/src/showcase/app/ix-blazor/showcase.blazor/Shared/NavMenu.razor index fd84ec043..29d613ade 100644 --- a/src/showcase/app/ix-blazor/showcase.blazor/Shared/NavMenu.razor +++ b/src/showcase/app/ix-blazor/showcase.blazor/Shared/NavMenu.razor @@ -30,6 +30,7 @@ + diff --git a/src/showcase/app/src/ShowcaseContext.st b/src/showcase/app/src/ShowcaseContext.st index 3c8f882c3..8ddda6493 100644 --- a/src/showcase/app/src/ShowcaseContext.st +++ b/src/showcase/app/src/ShowcaseContext.st @@ -43,6 +43,7 @@ CLASS ShowcaseContext EXTENDS AXOpen.Core.AxoContext // ---- Core: AxoMessaging ---- AxoMessengers : AxoStaticMessengerExample.Messengers; AxoMessengersDocu : AxoStaticMessengerDocuExample.Messengers; + AxoIncidentBar : AxoIncidentBarExample.IncidentTopology; // ---- Core: AxoTextList ---- AxoTextListExampleContext : AxoTextListExample.AxoTextListExampleContext; @@ -185,6 +186,9 @@ CLASS ShowcaseContext EXTENDS AXOpen.Core.AxoContext THIS.InitializeRootObject(AxoMessengersDocu); AxoMessengersDocu.Execute(); + THIS.InitializeRootObject(AxoIncidentBar); + AxoIncidentBar.Execute(); + THIS.InitializeRootObject(AxoTextListExampleContext); AxoTextListExampleContext.Execute(); diff --git a/src/showcase/app/src/core/AXOpen.Messaging/AxoIncidentBarExample.st b/src/showcase/app/src/core/AXOpen.Messaging/AxoIncidentBarExample.st new file mode 100644 index 000000000..4fc67b9fe --- /dev/null +++ b/src/showcase/app/src/core/AXOpen.Messaging/AxoIncidentBarExample.st @@ -0,0 +1,157 @@ +USING AXOpen.Core; +USING AXOpen.Messaging; +USING AXOpen.Messaging.Static; + +NAMESPACE AxoIncidentBarExample + + // Demonstrates AxoCauseAnalyzer + AxoIncidentBarView with a nested twin-tree topology. + // Operator toggles condition BOOLs to fire Error/Critical messengers at multiple + // tree levels. The bar ranks the upper messenger as 'top cause' because it + // owns more downstream alarms (Symbol-prefix heuristic) and shares the burst window. + {S7.extern=ReadWrite} + CLASS IncidentTopology EXTENDS AXOpen.Core.AxoObject + // + VAR PUBLIC + {#ix-set:AttributeName = "<#Station (top of the topology)#>"} + station : Station; + END_VAR + // + + VAR PRIVATE + _rootObject : AxoObject; + END_VAR + + METHOD PUBLIC Execute + _rootObject.Run(THIS); + station.Run(_rootObject); + END_METHOD + END_CLASS + + {S7.extern=ReadWrite} + CLASS PUBLIC Station EXTENDS AXOpen.Core.AxoObject + // + VAR PUBLIC + {#ix-set:AttributeName = "<#Station-level alarm (Error)#>"} + {#ix-set:PlcTextList = "[1]:'<#Station emergency stop active#>':'<#Station has tripped the safety chain. Resolve all downstream issues before resetting.#>'"} + _station_messenger : AXOpen.Messaging.Static.AxoMessenger; + {#ix-set:AttributeName = "<#Trigger Station error#>"} + _station_error : BOOL; + + {#ix-attr:[Container(Layout.Stack)]} + drive : Drive; + + {#ix-attr:[Container(Layout.Stack)]} + conveyor : Conveyor; + END_VAR + // + + METHOD PUBLIC OVERRIDE Run + VAR_INPUT + inParent : IAxoObject; + END_VAR + SUPER.Run(inParent); + _station_messenger.Serve(THIS); + // + _station_messenger.ActivateOnCondition(ULINT#1, _station_error, eAxoMessageCategory#Error) + .RequireAcknowledgement(); + // + drive.Run(THIS); + conveyor.Run(THIS); + END_METHOD + END_CLASS + + {S7.extern=ReadWrite} + CLASS PUBLIC Drive EXTENDS AXOpen.Core.AxoObject + // + VAR PUBLIC + {#ix-set:AttributeName = "<#Drive over-current (Critical)#>"} + {#ix-set:PlcTextList = "[1]:'<#Drive over-current trip#>':'<#Inverter detected current above the safety limit. Check load and wiring.#>'"} + _drive_messenger : AXOpen.Messaging.Static.AxoMessenger; + {#ix-set:AttributeName = "<#Trigger Drive over-current#>"} + _drive_error : BOOL; + + {#ix-attr:[Container(Layout.Stack)]} + encoder : Encoder; + END_VAR + // + + METHOD PUBLIC OVERRIDE Run + VAR_INPUT + inParent : IAxoObject; + END_VAR + SUPER.Run(inParent); + _drive_messenger.Serve(THIS); + _drive_messenger.ActivateOnCondition(ULINT#1, _drive_error, eAxoMessageCategory#Critical) + .RequireAcknowledgement(); + encoder.Run(THIS); + END_METHOD + END_CLASS + + {S7.extern=ReadWrite} + CLASS PUBLIC Encoder EXTENDS AXOpen.Core.AxoObject + VAR PUBLIC + {#ix-set:AttributeName = "<#Encoder signal lost (Error)#>"} + {#ix-set:PlcTextList = "[1]:'<#Encoder signal lost#>':'<#The drive lost feedback from the encoder. Check cable continuity and connector seating.#>'"} + _encoder_messenger : AXOpen.Messaging.Static.AxoMessenger; + {#ix-set:AttributeName = "<#Trigger Encoder signal lost#>"} + _encoder_error : BOOL; + END_VAR + + METHOD PUBLIC OVERRIDE Run + VAR_INPUT + inParent : IAxoObject; + END_VAR + SUPER.Run(inParent); + _encoder_messenger.Serve(THIS); + _encoder_messenger.ActivateOnCondition(ULINT#1, _encoder_error, eAxoMessageCategory#Error) + .RequireAcknowledgement(); + END_METHOD + END_CLASS + + {S7.extern=ReadWrite} + CLASS PUBLIC Conveyor EXTENDS AXOpen.Core.AxoObject + VAR PUBLIC + {#ix-set:AttributeName = "<#Conveyor jammed (Error)#>"} + {#ix-set:PlcTextList = "[1]:'<#Conveyor jammed#>':'<#Detected motor stall while pulley still commanded. Clear the obstruction and reset.#>'"} + _conveyor_messenger : AXOpen.Messaging.Static.AxoMessenger; + {#ix-set:AttributeName = "<#Trigger Conveyor jam#>"} + _conveyor_error : BOOL; + + {#ix-attr:[Container(Layout.Stack)]} + sensor : Sensor; + END_VAR + + METHOD PUBLIC OVERRIDE Run + VAR_INPUT + inParent : IAxoObject; + END_VAR + SUPER.Run(inParent); + _conveyor_messenger.Serve(THIS); + _conveyor_messenger.ActivateOnCondition(ULINT#1, _conveyor_error, eAxoMessageCategory#Error) + .RequireAcknowledgement(); + sensor.Run(THIS); + END_METHOD + END_CLASS + + {S7.extern=ReadWrite} + CLASS PUBLIC Sensor EXTENDS AXOpen.Core.AxoObject + VAR PUBLIC + {#ix-set:AttributeName = "<#Sensor disconnected (Error)#>"} + {#ix-set:PlcTextList = "[1]:'<#Sensor disconnected#>':'<#Photo-eye lost present feedback. Verify alignment and 24V supply.#>'"} + _sensor_messenger : AXOpen.Messaging.Static.AxoMessenger; + {#ix-set:AttributeName = "<#Trigger Sensor disconnect#>"} + _sensor_error : BOOL; + END_VAR + + METHOD PUBLIC OVERRIDE Run + VAR_INPUT + inParent : IAxoObject; + END_VAR + SUPER.Run(inParent); + _sensor_messenger.Serve(THIS); + _sensor_messenger.ActivateOnCondition(ULINT#1, _sensor_error, eAxoMessageCategory#Error) + .RequireAcknowledgement(); + END_METHOD + END_CLASS + +END_NAMESPACE From 7035b4071ac8fef3b64610664de6dc76b51d2ed0 Mon Sep 17 00:00:00 2001 From: Peter Kurhajec <61538034+PTKu@users.noreply.github.com> Date: Wed, 27 May 2026 13:47:19 +0200 Subject: [PATCH 4/4] docs: add AxoIncidentBar.md + central/per-lib CHANGELOG + showcase tags - New axopen/src/core/docs/AxoIncidentBar.md describing ranking formula, severity floor, Blazor mount pattern - toc.yml entry under Messengers (Alarms) - Per-lib CHANGELOG 0.56.0 entry; GitVersion next-version 0.55.1 -> 0.56.0 - Central CHANGELOG PR-style entry covering analyzer/bar/presenter/adapter + showcase + template wiring + topology fix + 28 new tests - Showcase razor BarMount + ProviderCreate tagged regions so docs reference real code via [!code-*[]] directives Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 34 ++++ GitVersion.yml | 2 +- src/core/docs/AxoIncidentBar.md | 152 ++++++++++++++++++ src/core/docs/CHANGELOG.md | 12 ++ src/core/docs/toc.yml | 4 +- .../Pages/core/AxoIncidentBar.razor | 12 +- 6 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 src/core/docs/AxoIncidentBar.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 17120b311..1c9bd33ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +### [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`. + +- feat: `AxoCauseAnalyzer` (`AXOpen.Messaging.Static`) — heuristic ranking layered on `AxoMessageProvider`. Scores each Error-or-above active messenger by severity (operator-actionability map: Critical=1.0, Error=0.9, ProgrammingError=0.85, Warning=0.6, Potential=0.4, Info=0.1), burst-root (earliest `Risen` within sliding `BurstWindow`, default 8 s, anchored on latest `Risen`), twin-tree topology (container-Symbol-prefix `DownstreamCount`), acknowledgement state, and age decay. `Changed` event fires only on top-cause symbol flip. Anti-strobe hold (`HoldDuration` default 2 s) suppresses PLC-cycle mid-read empty publishes. Static factory `AxoCauseAnalyzer.Create(AxoMessageProvider, options?, nowUtc?)` wires the adapter pipeline. +- feat: `AxoIncidentBarView` (`AXOpen.Core.Blazor`, namespace `AXOpen.Messaging.Static`) — sticky in-flow Blazor component (zero height when idle, no layout reflow) that renders the top probable cause as a severity-colored bar (`shadow-glow-{danger|warning|info}` + flat `bg-{token}/15` tint — color token matches the glow). `animate-pulse` for Critical/ProgrammingError until acknowledged. Click-to-expand panel shows top-5 candidates with score, evidence (burst-root, downstream count), optimistic Acknowledge, admin-gated Restore (``). Adaptive polling — 750 ms when any Error+ active, 2500 ms idle — using two-tier batch reads via the existing provider. `aria-live="polite"` for accessibility. +- feat: `AxoIncidentBarPresenter` — pure-logic seam. Exposes `CurrentState` (visibility, severity bucket, pulse flag, rows, ack-pending markers) and static Tailwind class mappers (`GlowClass`, `BadgeClass`, `BackgroundClass`, `ToSeverityBucket`). All decision logic is xUnit-testable without rendering or twin scaffolding. `IncidentBarSeverity` enum split into `Critical` / `Error` / `Warning` / `Info` / `None`. +- feat: `IRankableMessage` + `AxoMessengerRankableAdapter` — delegate-driven projection (`Func`, `Func`, etc.) so tests fake fields without standing up an `AxoMessenger`. Delegates re-invoke on each access so the analyzer sees the latest batch-read value. +- feat: Default `CauseSeverityFloor = eAxoMessageCategory.Error`. Warning/Potential/Info still count in `ActiveCount` / `PeakSeverity` (global indicators stay accurate) but never enter the cause ranking. Configurable via `AxoCauseAnalyzerOptions`. +- fix: Topology heuristic now compares CONTAINER symbols (strip last segment), not messenger Symbols directly. Earlier draft treated sibling messengers as unrelated even when their parent components nested. New test `Topology_uses_container_prefix_not_messenger_symbol_prefix` locks the realistic twin-tree shape. +- feat: Showcase `AxoIncidentBarExample.st` (nested `Station` → `Drive` → `Encoder` + `Conveyor` → `Sensor`, each with its own `AxoMessenger` and condition flag) plus `Pages/core/AxoIncidentBar.razor` (Live Bar tab with operator controls, Topology code tab with snippet refs, Heuristic tab with ranking formula). NavMenu entry under Core; search registry entry. +- feat: Template `axopen.template.simple` — `MainLayout.razor` mounts `` once per layout, cascades the provider so `GeneralAlarms.razor` consumes the same provider instance instead of walking the twin tree a second time. +- feat: Template `tailwind.css` extended with `@source` paths for `axopen/src/core/src/AXOpen.Core/**/*.cs` and `AXOpen.Core.Blazor/**/*.razor` so future bar class additions get JIT-compiled. +- docs: Added `src/core/docs/AxoIncidentBar.md` (ranking formula, severity floor, Blazor mount pattern). Cross-linked from `src/core/docs/toc.yml` under "Messengers (Alarms)". Appended `0.56.0` entry to `src/core/docs/CHANGELOG.md` (minor bump from `0.55.1`, GitVersion `next-version` updated). +- test: 28 new tests in `src/core/tests/AXOpen.Core.Tests/Messaging/` — `AxoCauseAnalyzerTests` (15: severity-floor, severity map, burst window, sliding burst, topology container prefix, ack de-prio, TopN clamp, anti-strobe hold, Changed event semantics, age decay), `AxoMessengerRankableAdapterTests` (2: projection + delegate re-invocation), `AxoCauseAnalyzerFactoryTests` (2: null guard + empty provider), `AxoIncidentBarPresenterTests` (26: visibility, severity bucket, pulse, additional count, rows, ack-pending, idle hysteresis, CSS class mapping, background-color-matches-glow invariant). Total: 69/69 green. + +**Impact:** +- Operators see a single severity-colored bar above the layout with the highest-confidence root cause of the current incident, instead of scanning a flat alarm list. +- The bar collapses to zero height when no Error+ is active — no permanent UI cost when the line is healthy. +- Applications opt in by mounting one component in their `MainLayout.razor` and creating one provider. No ST source changes; no `AxoMessenger` API change. +- Engineers writing custom HMI surfaces can consume `AxoCauseAnalyzer` directly (events + `TopCause` / `ProbableCauses` properties) without the Blazor view. + +**Risks/Review:** +- Severity-floor default is `Error`. Warning-only incidents do NOT raise the bar (intentional: operator noise reduction). Override via `AxoCauseAnalyzerOptions.CauseSeverityFloor` if a deployment needs Warning-level surfacing. +- `bg-{token}/15` and `shadow-glow-{token}` Tailwind classes must be present in the host app's compiled `momentum.css`. The template app's `tailwind.css` `@source` glob now covers the relevant axopen paths — rebuild required (`tailwind.ps1`) on first integration. +- Bar polling uses `System.Threading.Timer`; `IAsyncDisposable` cleans it up. If hosted in a Blazor Server circuit with frequent reconnects, monitor for orphaned timers under stress. +- `AxoIncidentBarView` consumes `AuthenticationStateProvider` cascaded parameter for the admin-gated Restore button. If the host app routes the bar outside an `AuthorizeRouteView` boundary, the cascading parameter is `null` and Restore degrades to no-op (no exception). + +**Testing:** +- `dotnet test src/core/tests/AXOpen.Core.Tests/` — 69/69 green (17 pre-existing + 52 messaging). +- `dotnet build src/core/src/AXOpen.Core.Blazor/` — Razor compiles clean. +- `dotnet build src/showcase/app/ix-blazor/showcase.blazor/` after `apax ib` — 0 errors. Showcase page navigates to `/core/AxoIncidentBar` and renders the live bar with the topology controls. +- `dotnet build axopen.template.simple/axpansion/server/` — 0 errors. `MainLayout` cascades the provider; `GeneralAlarms.razor` consumes it without creating a second instance. + ### [KUKA] KRC5 raw data exchange, coordinate-mirror diagnostics, and auto-mode severity fix ([#1151](https://github.com/Inxton/AXOpen/pull/1151)) **Note:** KRC5-only change in `src/components.kuka.robotics/`. `AxoKrc5` now diverges from `AxoKrc4` in three respects — `AxoKrc4` is intentionally left unchanged in this PR. Fixes #1148. diff --git a/GitVersion.yml b/GitVersion.yml index b35bfc037..ed89a970a 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,5 +1,5 @@ mode: ContinuousDeployment -next-version: 0.55.1 +next-version: 0.56.0 branches: main: regex: ^master$|^main$ diff --git a/src/core/docs/AxoIncidentBar.md b/src/core/docs/AxoIncidentBar.md new file mode 100644 index 000000000..dfd64c486 --- /dev/null +++ b/src/core/docs/AxoIncidentBar.md @@ -0,0 +1,152 @@ +# AxoIncidentBar + +*Probable-cause ranking + persistent operator-facing incident bar over `AxoMessenger`.* + +`AxoCauseAnalyzer` ranks active `AxoMessenger` instances and surfaces the most +likely *cause* of the current failure pattern. `AxoIncidentBarView` renders the +top probable cause as a persistent, severity-colored bar at the top of the +application layout, with a click-to-expand panel listing additional candidates, +score breakdown, and acknowledgement controls. + +The cause analyzer reads only what `AxoMessenger` already exposes — no PLC +source changes are required to opt in. See [AxoMessenger](AxoMessenger.md) for +the underlying messaging primitive. + +--- + +## Why it exists + +`AxoMessageProvider` returns a flat list of all active messengers. During a +real cascade failure (a sensor trips → conveyor stops → station enters E-stop) +the operator sees three simultaneous alarms with no indication of which one is +the *cause* versus a *symptom*. `AxoCauseAnalyzer` applies a heuristic to score +each active messenger and rank the most likely root cause to the top of the +list. `AxoIncidentBarView` then renders that ranking as a single, visually +unmissable bar so the operator's attention lands on the right thing first. + +--- + +## Severity floor + +By default only **Error**, **ProgrammingError**, and **Critical** messages +enter the cause ranking. Warnings, Potentials, and Infos still count toward +`AxoMessageProvider.ActiveMessagesCount` (so the existing badge indicators stay +accurate), but they never appear in the bar. The floor is configurable via +`AxoCauseAnalyzerOptions.CauseSeverityFloor`. + +--- + +## Ranking formula + +Each Error-or-above active messenger is scored: + +``` +Score = 0.40 * severity_weight(Category) + + 0.30 * (is_burst_root ? 1 : 0) + + 0.20 * log10(1 + DownstreamCount) + + 0.10 * (is_acknowledged ? 0 : 1) + - 0.02 * minutes_since_risen +``` + +| Term | Meaning | +|------|---------| +| `severity_weight` | Critical=1.0, Error=0.9, ProgrammingError=0.85, Warning=0.6, Potential=0.4, Info=0.1 — operator-actionability map, not enum ordinals | +| `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 | + +**Anti-strobe**: when the source briefly reads empty mid-PLC-cycle, the +published top cause is held for `HoldDuration` (default 2 s) before clearing. + +**Idle hysteresis**: the bar itself remains visible for `IdleHysteresis` +(default 2 s) after the last cause clears, then collapses to zero height. + +--- + +# [CONTROLLER](#tab/controller) + +## Nested twin-tree topology + +The cause analyzer's topology heuristic identifies a parent component as +"owner" of its descendants' alarms via Symbol prefix. To exercise it, declare +messengers at multiple tree levels: + +[!code-pascal[](../../showcase/app/src/core/AXOpen.Messaging/AxoIncidentBarExample.st?name=TopologyDeclaration)] + +The Station container holds a Drive sub-component (which holds an Encoder +leaf) and a Conveyor sub-component (which holds a Sensor leaf). Each level +declares its own `AxoMessenger`. + +## Activate the messenger + +Standard `ActivateOnCondition` works unchanged — the analyzer never needs the +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. + +# [BLAZOR](#tab/blazor) + +## Create a provider + +`AxoIncidentBarView` consumes an `AxoMessageProvider`. Create it once per +layout / circuit, scoped to the relevant twin object root: + +[!code-csharp[](../../showcase/app/ix-blazor/showcase.blazor/Pages/core/AxoIncidentBar.razor?name=ProviderCreate)] + +## Mount the bar + +Drop `AxoIncidentBarView` into your layout (typically near the top of +`MainLayout.razor`, above `@Body`): + +[!code-html[](../../showcase/app/ix-blazor/showcase.blazor/Pages/core/AxoIncidentBar.razor?name=BarMount)] + +The bar is in-flow — it has zero height when no Error+ cause is active, so +nothing else needs to reflow. Severity drives the color (`shadow-glow-danger` ++ `bg-danger/15` for Error/Critical, `warning` and `info` for the other +buckets); Critical and ProgrammingError additionally animate with +`animate-pulse` until acknowledged. + +## Parameters + +| Parameter | Type | Default | Purpose | +|-----------|------|---------|---------| +| `Provider` | `AxoMessageProvider` | required | Source of active messengers | +| `Options` | `AxoCauseAnalyzerOptions?` | `null` (defaults) | Override `BurstWindow`, `HoldDuration`, `IdleHysteresis`, `TopN`, `CauseSeverityFloor` | +| `PlcLabel` | `string?` | `null` | Optional prefix for multi-PLC stacked deployments | +| `AllowRestore` | `bool` | `false` | Show admin-only "Restore parent task" button in expanded panel | +| `Class` | `string?` | `null` | Extra Tailwind classes appended to the outer card | +| `ActivePollingMs` | `int` | `750` | Tier-2 batch-read cadence while any Error+ active | +| `IdlePollingMs` | `int` | `2500` | Tier-1 lightweight cadence when idle | + +*** + +## Multi-PLC deployments + +Pass each PLC context as its own provider and stack the bars vertically. The +analyzer's topology check uses `Symbol` prefix per-PLC, which is naturally +correct (no cross-PLC parenting). + +## Programmatic access + +The analyzer can be used without the bar. `AxoCauseAnalyzer.Create(provider)` +returns an instance whose `TopCause`, `ProbableCauses`, `ActiveCount`, +`PeakSeverity`, and `Changed` event are usable from any C# host (custom HMI, +logging sinks, external monitoring). `AxoIncidentBarPresenter` provides a +pure-logic seam (no rendering) for custom UI shells. + +## Testing + +The analyzer is unit-tested via `IRankableMessage` — a thin abstraction over +`AxoMessenger` that takes plain delegates, so tests do not need twin +scaffolding. The full ranking spec is locked in +`axopen/src/core/tests/AXOpen.Core.Tests/Messaging/AxoCauseAnalyzerTests.cs` +and the presenter logic in `AxoIncidentBarPresenterTests.cs`. + +See also [AxoMessenger](AxoMessenger.md) · [AxoLogger](AxoLogger.md). diff --git a/src/core/docs/CHANGELOG.md b/src/core/docs/CHANGELOG.md index 78026bb2b..948ac9c7e 100644 --- a/src/core/docs/CHANGELOG.md +++ b/src/core/docs/CHANGELOG.md @@ -12,6 +12,18 @@ {axopen-version} replace this with the current settings in GitVersion.yml file. --> +### 0.56.0 + +**New features:** +- Added `AxoCauseAnalyzer` (`AXOpen.Messaging.Static`) — heuristic probable-cause ranking layered on `AxoMessageProvider`. Scores active Error+ messengers by severity, burst-root (earliest within sliding `BurstWindow`), twin-tree topology (container-Symbol-prefix `DownstreamCount`), acknowledgement state, and age decay. Hold-cached against PLC-cycle strobe; `Changed` event fires only on top-cause symbol flip. +- Added `AxoIncidentBarView` (`AXOpen.Core.Blazor`, `AXOpen.Messaging.Static`) — sticky in-flow Blazor component that renders the top probable cause as a severity-colored bar with click-to-expand panel, optimistic acknowledge, admin-gated restore, `aria-live=polite` for accessibility, adaptive 750 ms (active) / 2500 ms (idle) polling cadence using two-tier batch reads. +- Added `AxoIncidentBarPresenter` — pure-logic seam exposing `CurrentState` (visibility, severity bucket, pulse flag, rows, ack-pending markers) and static Tailwind class mappers (`GlowClass`, `BadgeClass`, `BackgroundClass`) for custom UI shells. +- Added `IRankableMessage` + `AxoMessengerRankableAdapter` — delegate-driven projection of `AxoMessenger` so the analyzer can be unit-tested without twin scaffolding. +- Added showcase `AxoIncidentBarExample.st` (nested Station → Drive → Encoder + Conveyor → Sensor topology) and `Pages/core/AxoIncidentBar.razor` page demonstrating the bar end-to-end. + +**Other:** +- Documentation: added `AxoIncidentBar.md` describing the ranking formula, severity floor (default Error), and Blazor mount pattern. Cross-linked from `AxoMessenger.md` via TOC. + ### 0.43.0 **New features:** diff --git a/src/core/docs/toc.yml b/src/core/docs/toc.yml index a0c73a246..4a27dce3f 100644 --- a/src/core/docs/toc.yml +++ b/src/core/docs/toc.yml @@ -31,10 +31,12 @@ href : AxoStep.md - name : AxoSequencerContainer href : AxoSequencerContainer.md - - name : Messengers (Alarms) + - name : Messengers (Alarms) items: - name : AxoMessenger href : AxoMessenger.md + - name : AxoIncidentBar + href : AxoIncidentBar.md - name : Logging items: - name : AxoLogger diff --git a/src/showcase/app/ix-blazor/showcase.blazor/Pages/core/AxoIncidentBar.razor b/src/showcase/app/ix-blazor/showcase.blazor/Pages/core/AxoIncidentBar.razor index 7f451f2d5..ae31f1991 100644 --- a/src/showcase/app/ix-blazor/showcase.blazor/Pages/core/AxoIncidentBar.razor +++ b/src/showcase/app/ix-blazor/showcase.blazor/Pages/core/AxoIncidentBar.razor @@ -70,7 +70,12 @@

Mounted with a provider scoped to the showcase topology below. Default severity floor is Error.

@if (_provider is not null) { - + + + } @@ -160,7 +165,10 @@ protected override void OnInitialized() { - _provider = AxoMessageProvider.Create(new[] { (AXSharp.Connector.ITwinObject)Entry.Plc.Ctx.AxoIncidentBar }); + // + _provider = AxoMessageProvider.Create( + new[] { (AXSharp.Connector.ITwinObject)Entry.Plc.Ctx.AxoIncidentBar }); + // } protected override async Task OnInitializedAsync()