Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor
T
Joseph Doherty dc9c0c950c rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.

External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
  MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths

Also fixes two tests that were not rename-related but became visible
while validating the rename:

- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
  gateway service correctly maps to RpcException(Cancelled) per gRPC
  convention was being misclassified as a stream fault. Added a sibling
  catch on RpcException with StatusCode.Cancelled.

- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
  and made it accept either a .git marker OR a .sln/.slnx next to src/
  so the worker-exe walker works in non-git working copies.

clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.

Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
  Tests: 472/472 pass
  Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
  IntegrationTests: 18/18 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:22:23 -04:00

287 lines
10 KiB
Plaintext

@page "/alarms"
@page "/dashboard/alarms"
@implements IAsyncDisposable
@inject IDashboardLiveDataService LiveData
@inject IOptions<GatewayOptions> GatewayOptions
<PageTitle>Dashboard Alarms</PageTitle>
<div class="dashboard-page-header">
<div>
<h1>Alarms</h1>
<div class="text-secondary">@HeaderLine()</div>
</div>
</div>
@if (!GatewayOptions.Value.Alarms.Enabled)
{
<div class="alert alert-danger">
Alarm auto-subscribe is disabled (<code>MxGateway:Alarms:Enabled</code> is false). The
dashboard session is not subscribed to any alarm provider, so this list will stay empty.
Enable alarms in configuration and restart the gateway.
</div>
}
@if (!string.IsNullOrWhiteSpace(_queryError))
{
<div class="alert alert-danger">Alarm query failed: @_queryError</div>
}
<section class="metric-grid compact">
<MetricCard Label="Active (unacked)" Value="@_unackedCount.ToString("N0")" />
<MetricCard Label="Acknowledged" Value="@_ackedCount.ToString("N0")" />
<MetricCard Label="Total Active" Value="@_alarms.Count.ToString("N0")" />
<MetricCard Label="Showing" Value="@FilteredAlarms().Count.ToString("N0")" />
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Filters</h2>
</div>
<div class="alarm-filters">
<label class="alarm-filter-check">
<input type="checkbox" @bind="_showActive" />
<span>Active (unacked)</span>
</label>
<label class="alarm-filter-check">
<input type="checkbox" @bind="_showAcked" />
<span>Acknowledged</span>
</label>
<div class="alarm-filter-field">
<label class="form-label" for="alarm-area">Area</label>
<select id="alarm-area" class="form-select form-select-sm" @bind="_areaFilter">
<option value="">All areas</option>
@foreach (string area in Areas())
{
<option value="@area">@area</option>
}
</select>
</div>
<div class="alarm-filter-field">
<label class="form-label" for="alarm-sev-min">Min severity</label>
<input id="alarm-sev-min" type="number" class="form-control form-control-sm" @bind="_minSeverity" />
</div>
<div class="alarm-filter-field">
<label class="form-label" for="alarm-sev-max">Max severity</label>
<input id="alarm-sev-max" type="number" class="form-control form-control-sm" @bind="_maxSeverity" />
</div>
<div class="alarm-filter-field alarm-filter-grow">
<label class="form-label" for="alarm-search">Search</label>
<input id="alarm-search" class="form-control form-control-sm"
placeholder="Reference, source or description…"
@bind="_search" @bind:event="oninput" />
</div>
</div>
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Active Alarms</h2>
</div>
@{
IReadOnlyList<DashboardActiveAlarm> rows = FilteredAlarms();
}
@if (rows.Count == 0)
{
<div class="empty-state">
@if (_alarms.Count == 0)
{
<span>No alarms are currently Active or ActiveAcked.</span>
}
else
{
<span>No alarms match the current filters.</span>
}
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">State</th>
<th scope="col">Severity</th>
<th scope="col">Alarm Reference</th>
<th scope="col">Source</th>
<th scope="col">Type</th>
<th scope="col">Area</th>
<th scope="col">Last Transition</th>
<th scope="col">Operator</th>
</tr>
</thead>
<tbody>
@foreach (DashboardActiveAlarm alarm in rows)
{
<tr>
<td><span class="alarm-state @StateClass(alarm.State)">@StateText(alarm.State)</span></td>
<td class="alarm-severity">@alarm.Severity</td>
<td>
<code>@alarm.Reference</code>
@if (!string.IsNullOrWhiteSpace(alarm.Description))
{
<div class="alarm-desc">@alarm.Description</div>
}
</td>
<td>@DashboardDisplay.Text(alarm.Source)</td>
<td>@DashboardDisplay.Text(alarm.AlarmType)</td>
<td>@DashboardDisplay.Text(alarm.Area)</td>
<td>@(alarm.LastTransition is { } ts ? DashboardDisplay.DateTime(ts) : "-")</td>
<td>
@DashboardDisplay.Text(alarm.OperatorUser)
@if (!string.IsNullOrWhiteSpace(alarm.OperatorComment))
{
<div class="alarm-desc">@alarm.OperatorComment</div>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="browse-search-note">
Cleared alarms are not retained — this list reflects only alarms currently Active or
ActiveAcked, refreshed every 3 seconds.
</div>
</section>
@code {
private readonly List<DashboardActiveAlarm> _alarms = [];
private string? _queryError;
private int? _workerPid;
private DateTimeOffset? _lastRefresh;
private int _unackedCount;
private int _ackedCount;
private bool _showActive = true;
private bool _showAcked;
private string _areaFilter = string.Empty;
private int _minSeverity;
private int _maxSeverity = 1000;
private string _search = string.Empty;
private readonly CancellationTokenSource _cts = new();
private Task? _pollTask;
/// <inheritdoc />
protected override void OnInitialized()
{
_pollTask = PollLoopAsync();
}
private string HeaderLine()
{
string refreshed = _lastRefresh is { } at
? $"refreshed {DashboardDisplay.DateTime(at)}"
: "awaiting first refresh";
return _workerPid is int pid ? $"{refreshed} · worker pid {pid}" : refreshed;
}
private IReadOnlyList<string> Areas()
{
return _alarms
.Select(alarm => alarm.Area)
.Where(area => !string.IsNullOrWhiteSpace(area))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(area => area, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private IReadOnlyList<DashboardActiveAlarm> FilteredAlarms()
{
string query = _search.Trim();
return _alarms
.Where(MatchesState)
.Where(alarm => _areaFilter.Length == 0
|| string.Equals(alarm.Area, _areaFilter, StringComparison.OrdinalIgnoreCase))
.Where(alarm => alarm.Severity >= _minSeverity && alarm.Severity <= _maxSeverity)
.Where(alarm => query.Length == 0
|| alarm.Reference.Contains(query, StringComparison.OrdinalIgnoreCase)
|| alarm.Source.Contains(query, StringComparison.OrdinalIgnoreCase)
|| alarm.Description.Contains(query, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(alarm => alarm.Severity)
.ThenByDescending(alarm => alarm.LastTransition ?? DateTimeOffset.MinValue)
.ToArray();
}
private bool MatchesState(DashboardActiveAlarm alarm)
{
return alarm.State switch
{
AlarmConditionState.Active => _showActive,
AlarmConditionState.ActiveAcked => _showAcked,
_ => true,
};
}
private static string StateText(AlarmConditionState state)
{
return state switch
{
AlarmConditionState.Active => "Active",
AlarmConditionState.ActiveAcked => "Acked",
AlarmConditionState.Inactive => "Inactive",
_ => "Unknown",
};
}
private static string StateClass(AlarmConditionState state)
{
return state switch
{
AlarmConditionState.Active => "alarm-state-active",
AlarmConditionState.ActiveAcked => "alarm-state-acked",
_ => "alarm-state-other",
};
}
private async Task PollLoopAsync()
{
try
{
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
using PeriodicTimer timer = new(TimeSpan.FromSeconds(3));
while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
{
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
}
}
private async Task RefreshAlarmsAsync()
{
DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
_queryError = result.Error;
_workerPid = result.WorkerProcessId;
_lastRefresh = DateTimeOffset.UtcNow;
_alarms.Clear();
_alarms.AddRange(result.Alarms);
_unackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.Active);
_ackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.ActiveAcked);
StateHasChanged();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_pollTask is not null)
{
try
{
await _pollTask;
}
catch (OperationCanceledException)
{
}
}
_cts.Dispose();
GC.SuppressFinalize(this);
}
}