feat(ui): enrich DebugView alarm table with severity + condition state + native metadata
This commit is contained in:
@@ -191,9 +191,10 @@
|
|||||||
<thead class="table-light sticky-top">
|
<thead class="table-light sticky-top">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Alarm</th>
|
<th>Alarm</th>
|
||||||
|
<th>Kind</th>
|
||||||
<th>State</th>
|
<th>State</th>
|
||||||
|
<th>Sev</th>
|
||||||
<th>Level</th>
|
<th>Level</th>
|
||||||
<th>Priority</th>
|
|
||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -201,18 +202,44 @@
|
|||||||
@foreach (var alarm in FilteredAlarmStates)
|
@foreach (var alarm in FilteredAlarmStates)
|
||||||
{
|
{
|
||||||
<tr class="@GetAlarmRowClass(alarm.State)"
|
<tr class="@GetAlarmRowClass(alarm.State)"
|
||||||
title="@(string.IsNullOrEmpty(alarm.Message) ? null : alarm.Message)">
|
title="@BuildAlarmTooltip(alarm)">
|
||||||
<td class="small">
|
<td class="small">
|
||||||
@alarm.AlarmName
|
@alarm.AlarmName
|
||||||
@if (!string.IsNullOrEmpty(alarm.Message))
|
@if (!string.IsNullOrEmpty(alarm.Message))
|
||||||
{
|
{
|
||||||
<span class="ms-1 text-info" aria-label="Has operator message">💬</span>
|
<span class="ms-1 text-info" aria-label="Has operator message">💬</span>
|
||||||
}
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(alarm.SourceReference))
|
||||||
|
{
|
||||||
|
<div class="text-muted font-monospace text-truncate" style="font-size: .7rem; max-width: 180px;"
|
||||||
|
title="@alarm.SourceReference">@alarm.SourceReference</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge @GetKindBadge(alarm.Kind)"
|
||||||
|
aria-label="@($"Alarm kind: {alarm.Kind}")">@FormatKind(alarm.Kind)</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge @GetAlarmStateBadge(alarm.State)"
|
<span class="badge @GetAlarmStateBadge(alarm.State)"
|
||||||
aria-label="@($"Alarm state: {alarm.State}")">@alarm.State</span>
|
aria-label="@($"Alarm state: {alarm.State}")">@alarm.State</span>
|
||||||
|
@if (alarm.Kind != AlarmKind.Computed)
|
||||||
|
{
|
||||||
|
@if (alarm.Condition.Active && !alarm.Condition.Acknowledged)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark ms-1" aria-label="Unacknowledged">Unacked</span>
|
||||||
|
}
|
||||||
|
@if (alarm.Condition.Shelve != AlarmShelveState.Unshelved)
|
||||||
|
{
|
||||||
|
<span class="badge bg-info text-dark ms-1" title="@alarm.Condition.Shelve"
|
||||||
|
aria-label="@($"Shelved: {alarm.Condition.Shelve}")">Shelved</span>
|
||||||
|
}
|
||||||
|
@if (alarm.Condition.Suppressed)
|
||||||
|
{
|
||||||
|
<span class="badge bg-info text-dark ms-1" aria-label="Suppressed">Suppressed</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="small font-monospace">@alarm.Condition.Severity</td>
|
||||||
<td>
|
<td>
|
||||||
@if (alarm.Level != AlarmLevel.None)
|
@if (alarm.Level != AlarmLevel.None)
|
||||||
{
|
{
|
||||||
@@ -224,7 +251,6 @@
|
|||||||
<span class="text-muted small">—</span>
|
<span class="text-muted small">—</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="small">@alarm.Priority</td>
|
|
||||||
<td class="small text-muted"
|
<td class="small text-muted"
|
||||||
title="@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
|
title="@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
|
||||||
@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss")
|
@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss")
|
||||||
@@ -284,7 +310,8 @@
|
|||||||
string.IsNullOrWhiteSpace(_alarmFilter)
|
string.IsNullOrWhiteSpace(_alarmFilter)
|
||||||
? _alarmStates.Values.OrderBy(a => a.AlarmName).ToList()
|
? _alarmStates.Values.OrderBy(a => a.AlarmName).ToList()
|
||||||
: _alarmStates.Values
|
: _alarmStates.Values
|
||||||
.Where(a => a.AlarmName.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase))
|
.Where(a => a.AlarmName.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| a.SourceReference.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase))
|
||||||
.OrderBy(a => a.AlarmName)
|
.OrderBy(a => a.AlarmName)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -575,6 +602,40 @@
|
|||||||
_ => "bg-secondary"
|
_ => "bg-secondary"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Badge class distinguishing computed (neutral) from native (info) alarms.</summary>
|
||||||
|
private static string GetKindBadge(AlarmKind kind) => kind switch
|
||||||
|
{
|
||||||
|
AlarmKind.Computed => "bg-secondary",
|
||||||
|
_ => "bg-info text-dark"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Short display label for the alarm kind.</summary>
|
||||||
|
private static string FormatKind(AlarmKind kind) => kind switch
|
||||||
|
{
|
||||||
|
AlarmKind.NativeOpcUa => "OPC UA",
|
||||||
|
AlarmKind.NativeMxAccess => "MxAccess",
|
||||||
|
_ => "Computed"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the row tooltip from the alarm's operator message plus native
|
||||||
|
/// metadata (type, category, operator, raise time, current/limit value).
|
||||||
|
/// Returns null when there is nothing extra to show.
|
||||||
|
/// </summary>
|
||||||
|
private static string? BuildAlarmTooltip(AlarmStateChanged a)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (!string.IsNullOrEmpty(a.Message)) parts.Add(a.Message);
|
||||||
|
if (!string.IsNullOrEmpty(a.AlarmTypeName)) parts.Add($"Type: {a.AlarmTypeName}");
|
||||||
|
if (!string.IsNullOrEmpty(a.Category)) parts.Add($"Category: {a.Category}");
|
||||||
|
if (!string.IsNullOrEmpty(a.OperatorUser)) parts.Add($"By: {a.OperatorUser}");
|
||||||
|
if (!string.IsNullOrEmpty(a.OperatorComment)) parts.Add($"Comment: {a.OperatorComment}");
|
||||||
|
if (a.OriginalRaiseTime.HasValue) parts.Add($"Raised: {a.OriginalRaiseTime.Value.LocalDateTime:HH:mm:ss}");
|
||||||
|
if (!string.IsNullOrEmpty(a.CurrentValue)) parts.Add($"Value: {a.CurrentValue}");
|
||||||
|
if (!string.IsNullOrEmpty(a.LimitValue)) parts.Add($"Limit: {a.LimitValue}");
|
||||||
|
return parts.Count == 0 ? null : string.Join(" · ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
private static string FormatLevel(AlarmLevel level) => level switch
|
private static string FormatLevel(AlarmLevel level) => level switch
|
||||||
{
|
{
|
||||||
AlarmLevel.HighHigh => "HiHi",
|
AlarmLevel.HighHigh => "HiHi",
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||||
|
using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.DebugView;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 23: the DebugView alarm table surfaces enriched native alarm state —
|
||||||
|
/// kind, severity, source reference, and a composite condition badge set
|
||||||
|
/// (Unacked / Shelved / Suppressed).
|
||||||
|
/// </summary>
|
||||||
|
public class DebugViewAlarmTableTests : BunitContext
|
||||||
|
{
|
||||||
|
private IRenderedComponent<DebugViewPage> RenderPage()
|
||||||
|
{
|
||||||
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
|
||||||
|
var repo = Substitute.For<ITemplateEngineRepository>();
|
||||||
|
var siteRepo = Substitute.For<ISiteRepository>();
|
||||||
|
siteRepo.GetAllSitesAsync().Returns(new List<Site>());
|
||||||
|
Services.AddSingleton(repo);
|
||||||
|
Services.AddSingleton(siteRepo);
|
||||||
|
|
||||||
|
var comms = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
Services.AddSingleton(comms);
|
||||||
|
|
||||||
|
var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance);
|
||||||
|
var debugStream = new DebugStreamService(
|
||||||
|
comms, new ServiceCollection().BuildServiceProvider(), grpcFactory,
|
||||||
|
NullLogger<DebugStreamService>.Instance);
|
||||||
|
Services.AddSingleton(debugStream);
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
|
||||||
|
var stubAuth = new StubAuthStateProvider(new AuthenticationState(new ClaimsPrincipal(identity)));
|
||||||
|
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
|
||||||
|
Services.AddScoped(_ => new SiteScopeService(stubAuth));
|
||||||
|
|
||||||
|
return Render<DebugViewPage>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubAuthStateProvider : AuthenticationStateProvider
|
||||||
|
{
|
||||||
|
private readonly AuthenticationState _state;
|
||||||
|
public StubAuthStateProvider(AuthenticationState state) => _state = state;
|
||||||
|
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetField(DebugViewPage c, string name, object? value) =>
|
||||||
|
typeof(DebugViewPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(c, value);
|
||||||
|
|
||||||
|
private static Dictionary<string, AlarmStateChanged> AlarmStates(DebugViewPage c) =>
|
||||||
|
(Dictionary<string, AlarmStateChanged>)typeof(DebugViewPage)
|
||||||
|
.GetField("_alarmStates", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlarmTable_RendersNativeAlarm_WithSeverityKindAndShelvedBadge()
|
||||||
|
{
|
||||||
|
var cut = RenderPage();
|
||||||
|
|
||||||
|
var native = new AlarmStateChanged("inst", "T01.Hi", AlarmState.Active, 800, DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
Kind = AlarmKind.NativeOpcUa,
|
||||||
|
SourceReference = "ns=2;s=Tank01.Level.HiHi",
|
||||||
|
Condition = new AlarmConditionState(
|
||||||
|
Active: true, Acknowledged: false, Confirmed: null,
|
||||||
|
Shelve: AlarmShelveState.OneShotShelved, Suppressed: false, Severity: 800)
|
||||||
|
};
|
||||||
|
|
||||||
|
cut.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
SetField(cut.Instance, "_connected", true);
|
||||||
|
SetField(cut.Instance, "_snapshot",
|
||||||
|
new DebugViewSnapshot("inst", new List<AttributeValueChanged>(),
|
||||||
|
new List<AlarmStateChanged> { native }, DateTimeOffset.UtcNow));
|
||||||
|
AlarmStates(cut.Instance)["T01.Hi"] = native;
|
||||||
|
});
|
||||||
|
cut.Render();
|
||||||
|
|
||||||
|
var markup = cut.Markup;
|
||||||
|
Assert.Contains("800", markup); // severity surfaced
|
||||||
|
Assert.Contains("Shelved", markup); // composite condition badge
|
||||||
|
Assert.Contains("OPC UA", markup); // native kind badge
|
||||||
|
Assert.Contains("ns=2;s=Tank01.Level.HiHi", markup); // source reference
|
||||||
|
Assert.Contains("Unacked", markup); // active + unacknowledged
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user