Add Browse and Alarms dashboard tabs

Browse renders the Galaxy hierarchy tree from IGalaxyHierarchyCache:
expandable areas/objects with attribute name, data type and the
alarm/historized flags, plus a name/reference filter. Right-click or
double-click an attribute to add it to a subscription panel that polls
live value, quality and source timestamp every two seconds.

Alarms lists the worker's currently-active alarm set via
IAlarmRpcDispatcher, defaulting to unacknowledged Active alarms with
filters for acknowledged alarms, area, severity range and text. It is
read-only and warns when alarm auto-subscribe is disabled.

Both tabs read live MXAccess data through a new singleton
DashboardLiveDataService that owns one shared, lazily-opened gateway
session (one worker) for the whole dashboard, re-opened transparently
if it faults or its lease expires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-21 13:53:28 -04:00
parent b39848b5f5
commit c1fe7fbc4a
15 changed files with 1763 additions and 2 deletions
@@ -10,6 +10,8 @@
<NavLink href="workers">Workers</NavLink>
<NavLink href="events">Events</NavLink>
<NavLink href="galaxy">Galaxy</NavLink>
<NavLink href="browse">Browse</NavLink>
<NavLink href="alarms">Alarms</NavLink>
<NavLink href="apikeys">API Keys</NavLink>
<NavLink href="settings">Settings</NavLink>
</nav>
@@ -0,0 +1,286 @@
@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);
}
}
@@ -0,0 +1,423 @@
@page "/browse"
@page "/dashboard/browse"
@implements IAsyncDisposable
@inject IGalaxyHierarchyCache GalaxyCache
@inject IDashboardLiveDataService LiveData
@using MxGateway.Contracts.Proto.Galaxy
@using MxGateway.Server.Galaxy
<PageTitle>Dashboard Browse</PageTitle>
<div class="dashboard-page-header">
<div>
<h1>Browse</h1>
<div class="text-secondary">@HeaderLine()</div>
</div>
<StatusBadge Text="@GalaxyCache.Current.Status.ToString()" />
</div>
<div class="browse-layout">
<section class="dashboard-section browse-panel">
<div class="section-heading">
<h2>Galaxy Hierarchy</h2>
</div>
<input class="form-control form-control-sm browse-search"
placeholder="Filter attributes by name or reference…"
@bind="Search"
@bind:event="oninput" />
@if (_roots.Count == 0)
{
<div class="empty-state">
No Galaxy hierarchy is cached yet. The hierarchy refreshes from the
Galaxy Repository in the background — check the Galaxy tab for status.
</div>
}
else if (!string.IsNullOrWhiteSpace(Search))
{
@if (_searchMatches.Count == 0)
{
<div class="empty-state">No attributes match &ldquo;@Search&rdquo;.</div>
}
else
{
<div class="browse-tree browse-search-results">
@foreach (GalaxyAttribute hit in _searchMatches)
{
GalaxyAttribute row = hit;
<div class="tree-attr"
title="@row.FullTagReference"
@oncontextmenu:preventDefault="true"
@oncontextmenu="@(args => ShowMenu(args, row))"
@ondblclick="@(() => AddTagAsync(row.FullTagReference))">
<span class="attr-icon">·</span>
<span class="attr-name">@row.FullTagReference</span>
<span class="attr-type">@FormatType(row)</span>
@if (row.IsAlarm)
{
<span class="attr-flag attr-flag-alarm">alarm</span>
}
@if (row.IsHistorized)
{
<span class="attr-flag attr-flag-hist">hist</span>
}
</div>
}
</div>
@if (_searchMatches.Count >= SearchResultLimit)
{
<div class="browse-search-note">Showing the first @SearchResultLimit matches — refine the filter.</div>
}
}
}
else
{
<div class="browse-tree">
@foreach (DashboardBrowseNode root in _roots)
{
<BrowseTreeNodeView Node="root"
OnAddTag="AddTagAsync"
OnTagContextMenu="OnTagContextMenu" />
}
</div>
<div class="browse-search-note">Double-click a tag, or right-click for the menu.</div>
}
</section>
<section class="dashboard-section browse-panel">
<div class="section-heading">
<h2>Subscription Panel</h2>
</div>
<div class="sub-panel-meta">
@if (_subscribed.Count > 0)
{
<span>@_subscribed.Count subscribed</span>
<span>·</span>
<span>refresh 2s</span>
@if (_workerPid is int pid)
{
<span>·</span>
<span>worker pid @pid</span>
}
<button type="button" class="btn btn-outline-secondary btn-sm sub-clear" @onclick="ClearAll">Clear all</button>
}
</div>
@if (!string.IsNullOrWhiteSpace(_readError))
{
<div class="alert alert-danger">Live read failed: @_readError</div>
}
@if (_subscribed.Count == 0)
{
<div class="empty-state">
No tags subscribed. Right-click a tag in the hierarchy and choose
<strong>Add to subscription panel</strong> (or double-click it) to watch its
live value, quality and source timestamp here.
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table sub-table">
<thead>
<tr>
<th scope="col">Tag</th>
<th scope="col">Value</th>
<th scope="col">Type</th>
<th scope="col">Quality</th>
<th scope="col">Updated</th>
<th scope="col" class="sub-actions-col"></th>
</tr>
</thead>
<tbody>
@foreach (string tag in _subscribed)
{
string key = tag;
DashboardTagValue? value = _values.GetValueOrDefault(key);
<tr>
<td><code>@key</code></td>
<td class="sub-value">@(value?.ValueText ?? "…")</td>
<td>@(value?.DataType ?? "-")</td>
<td>
@if (value is null)
{
<span class="text-secondary">…</span>
}
else
{
<span class="quality-chip @(value.QualityGood ? "quality-good" : "quality-bad")">
@value.Quality
</span>
@if (!string.IsNullOrWhiteSpace(value.Error))
{
<span class="sub-error" title="@value.Error">!</span>
}
}
</td>
<td title="@TimestampTooltip(value)">@TimestampText(key, value)</td>
<td class="sub-actions-col">
<button type="button"
class="btn btn-outline-secondary btn-sm"
@onclick="@(() => RemoveTag(key))">Remove</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
</div>
@if (_menuVisible)
{
<div class="context-menu-overlay"
@onclick="HideMenu"
@oncontextmenu:preventDefault="true"
@oncontextmenu="HideMenu"></div>
<div class="context-menu" style="left:@(_menuX)px; top:@(_menuY)px;">
<div class="context-menu-head">@(_menuAttribute?.AttributeName)</div>
<button type="button" class="context-menu-item" @onclick="AddMenuTagAsync">
Add to subscription panel
</button>
</div>
}
@code {
private const int SearchResultLimit = 300;
private IReadOnlyList<DashboardBrowseNode> _roots = [];
private string _search = string.Empty;
private IReadOnlyList<GalaxyAttribute> _searchMatches = [];
private readonly List<string> _subscribed = [];
private readonly Dictionary<string, DashboardTagValue> _values = new(StringComparer.Ordinal);
// Per-tag bookkeeping for the Updated column: the signature of the value
// last seen, and when that value/quality was first observed. Lets the
// column move only on a real change, not on every 2s poll.
private readonly Dictionary<string, string> _valueSignature = new(StringComparer.Ordinal);
private readonly Dictionary<string, DateTimeOffset> _observedChangeAt = new(StringComparer.Ordinal);
private string? _readError;
private int? _workerPid;
private bool _menuVisible;
private int _menuX;
private int _menuY;
private GalaxyAttribute? _menuAttribute;
private readonly CancellationTokenSource _cts = new();
private Task? _pollTask;
/// <inheritdoc />
protected override void OnInitialized()
{
_roots = DashboardBrowseTreeBuilder.Build(GalaxyCache.Current.Objects);
_pollTask = PollLoopAsync();
}
private string HeaderLine()
{
GalaxyHierarchyCacheEntry entry = GalaxyCache.Current;
return $"{entry.ObjectCount:N0} objects · {entry.AttributeCount:N0} attributes · "
+ $"{entry.AlarmAttributeCount:N0} alarm attributes";
}
private string Search
{
get => _search;
set
{
_search = value ?? string.Empty;
_searchMatches = ComputeSearch(_search);
}
}
private IReadOnlyList<GalaxyAttribute> ComputeSearch(string rawQuery)
{
string query = rawQuery.Trim();
if (query.Length == 0)
{
return [];
}
List<GalaxyAttribute> matches = [];
foreach (GalaxyObject galaxyObject in GalaxyCache.Current.Objects)
{
foreach (GalaxyAttribute attr in galaxyObject.Attributes)
{
if (attr.FullTagReference.Contains(query, StringComparison.OrdinalIgnoreCase)
|| attr.AttributeName.Contains(query, StringComparison.OrdinalIgnoreCase))
{
matches.Add(attr);
if (matches.Count >= SearchResultLimit)
{
return matches;
}
}
}
}
return matches;
}
private static string FormatType(GalaxyAttribute attr)
{
string baseType = string.IsNullOrWhiteSpace(attr.DataTypeName) ? "type?" : attr.DataTypeName;
if (!attr.IsArray)
{
return baseType;
}
return attr.ArrayDimensionPresent ? $"{baseType}[{attr.ArrayDimension}]" : $"{baseType}[]";
}
private Task OnTagContextMenu((MouseEventArgs Event, GalaxyAttribute Attribute) args)
{
ShowMenu(args.Event, args.Attribute);
return Task.CompletedTask;
}
private void ShowMenu(MouseEventArgs args, GalaxyAttribute attr)
{
_menuAttribute = attr;
_menuX = (int)args.ClientX;
_menuY = (int)args.ClientY;
_menuVisible = true;
}
private void HideMenu()
{
_menuVisible = false;
_menuAttribute = null;
}
private async Task AddMenuTagAsync()
{
GalaxyAttribute? attr = _menuAttribute;
HideMenu();
if (attr is not null)
{
await AddTagAsync(attr.FullTagReference);
}
}
private async Task AddTagAsync(string fullReference)
{
if (string.IsNullOrWhiteSpace(fullReference)
|| _subscribed.Contains(fullReference, StringComparer.Ordinal))
{
return;
}
_subscribed.Add(fullReference);
await RefreshValuesAsync();
}
private void RemoveTag(string tag)
{
_subscribed.Remove(tag);
_values.Remove(tag);
_valueSignature.Remove(tag);
_observedChangeAt.Remove(tag);
}
private void ClearAll()
{
_subscribed.Clear();
_values.Clear();
_valueSignature.Clear();
_observedChangeAt.Clear();
_readError = null;
}
// The MXAccess source timestamp when the worker supplies one, otherwise the
// time the dashboard first observed the current value/quality.
private string TimestampText(string tag, DashboardTagValue? value)
{
if (value is null)
{
return "…";
}
if (value.SourceTimestamp is { } source)
{
return DashboardDisplay.DateTime(source);
}
return _observedChangeAt.TryGetValue(tag, out DateTimeOffset observed)
? DashboardDisplay.DateTime(observed)
: "-";
}
private static string TimestampTooltip(DashboardTagValue? value)
{
return value?.SourceTimestamp is not null
? "MXAccess source timestamp."
: "When the dashboard first observed this value — MXAccess did not supply a source timestamp for this tag.";
}
private async Task PollLoopAsync()
{
try
{
using PeriodicTimer timer = new(TimeSpan.FromSeconds(2));
while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
{
await InvokeAsync(RefreshValuesAsync).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
}
}
private async Task RefreshValuesAsync()
{
if (_subscribed.Count == 0)
{
return;
}
string[] tags = [.. _subscribed];
DashboardLiveReadResult result = await LiveData.ReadAsync(tags, _cts.Token);
_readError = result.Error;
_workerPid = result.WorkerProcessId;
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (DashboardTagValue value in result.Values)
{
// Stamp the observed-change time only when the value/quality
// signature actually changes, so the Updated column does not
// tick on every poll for a static tag.
string signature = $"{value.ValueText}{value.Quality}{value.Ok}";
if (!_valueSignature.TryGetValue(value.TagAddress, out string? previous)
|| previous != signature)
{
_valueSignature[value.TagAddress] = signature;
_observedChangeAt[value.TagAddress] = now;
}
_values[value.TagAddress] = value;
}
StateHasChanged();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_pollTask is not null)
{
try
{
await _pollTask;
}
catch (OperationCanceledException)
{
}
}
_cts.Dispose();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,102 @@
@using MxGateway.Contracts.Proto.Galaxy
@*
Recursive Browse hierarchy node. Renders one Galaxy object, its child
objects (recursively), and its attributes as right-clickable tag rows.
Expansion state is local; children render only while expanded.
*@
<div class="tree-node">
<div class="tree-row @(Node.IsArea ? "tree-row-area" : "tree-row-object")">
@if (Node.HasChildren)
{
<button type="button" class="tree-toggle" @onclick="Toggle" aria-label="Toggle">
@(_expanded ? "▾" : "▸")
</button>
}
else
{
<span class="tree-toggle tree-toggle-empty"></span>
}
<span class="tree-label" @onclick="Toggle">
<span class="tree-icon">@(Node.IsArea ? "▣" : "◇")</span>
<span class="tree-name">@Node.DisplayName</span>
@if (!string.IsNullOrWhiteSpace(Node.Object.TagName)
&& !string.Equals(Node.Object.TagName, Node.DisplayName, StringComparison.Ordinal))
{
<code class="tree-tag">@Node.Object.TagName</code>
}
</span>
</div>
@if (_expanded)
{
<div class="tree-children">
@foreach (DashboardBrowseNode child in Node.Children)
{
<BrowseTreeNodeView Node="child" OnAddTag="OnAddTag" OnTagContextMenu="OnTagContextMenu" />
}
@foreach (GalaxyAttribute attr in Node.Attributes)
{
GalaxyAttribute row = attr;
<div class="tree-attr"
title="@row.FullTagReference"
@oncontextmenu:preventDefault="true"
@oncontextmenu="@(args => OnTagContextMenu.InvokeAsync((args, row)))"
@ondblclick="@(() => OnAddTag.InvokeAsync(row.FullTagReference))">
<span class="tree-toggle tree-toggle-empty"></span>
<span class="attr-icon">·</span>
<span class="attr-name">@row.AttributeName</span>
<span class="attr-type">@DisplayType(row)</span>
@if (row.IsAlarm)
{
<span class="attr-flag attr-flag-alarm">alarm</span>
}
@if (row.IsHistorized)
{
<span class="attr-flag attr-flag-hist">hist</span>
}
</div>
}
</div>
}
</div>
@code {
/// <summary>The hierarchy node this view renders.</summary>
[Parameter]
[EditorRequired]
public DashboardBrowseNode Node { get; set; } = null!;
/// <summary>Raised with a tag's full reference when the operator double-clicks it.</summary>
[Parameter]
public EventCallback<string> OnAddTag { get; set; }
/// <summary>Raised when an attribute row is right-clicked, for the context menu.</summary>
[Parameter]
public EventCallback<(MouseEventArgs Event, GalaxyAttribute Attribute)> OnTagContextMenu { get; set; }
private bool _expanded;
private void Toggle()
{
if (Node.HasChildren)
{
_expanded = !_expanded;
}
}
private static string DisplayType(GalaxyAttribute attribute)
{
string baseType = string.IsNullOrWhiteSpace(attribute.DataTypeName)
? "type?"
: attribute.DataTypeName;
if (attribute.IsArray)
{
return attribute.ArrayDimensionPresent
? $"{baseType}[{attribute.ArrayDimension}]"
: $"{baseType}[]";
}
return baseType;
}
}
@@ -0,0 +1,63 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Dashboard;
/// <summary>
/// One active-alarm row as shown on the dashboard Alarms tab. Projected
/// from an <see cref="ActiveAlarmSnapshot"/> so the Razor component never
/// touches protobuf types directly.
/// </summary>
public sealed record DashboardActiveAlarm(
string Reference,
string Provider,
string Area,
string Source,
string AlarmType,
int Severity,
AlarmConditionState State,
DateTimeOffset? LastTransition,
string OperatorUser,
string OperatorComment,
string Description)
{
/// <summary>Projects a worker active-alarm snapshot into a dashboard alarm row.</summary>
/// <param name="snapshot">The snapshot returned by <c>QueryActiveAlarms</c>.</param>
/// <returns>The projected dashboard alarm.</returns>
public static DashboardActiveAlarm FromSnapshot(ActiveAlarmSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
string provider = string.Empty;
string reference = snapshot.AlarmFullReference ?? string.Empty;
int bang = reference.IndexOf('!', StringComparison.Ordinal);
if (bang > 0)
{
provider = reference[..bang];
}
return new DashboardActiveAlarm(
Reference: reference,
Provider: provider,
Area: snapshot.Category ?? string.Empty,
Source: snapshot.SourceObjectReference ?? string.Empty,
AlarmType: snapshot.AlarmTypeName ?? string.Empty,
Severity: snapshot.Severity,
State: snapshot.CurrentState,
LastTransition: snapshot.LastTransitionTimestamp?.ToDateTimeOffset(),
OperatorUser: snapshot.OperatorUser ?? string.Empty,
OperatorComment: snapshot.OperatorComment ?? string.Empty,
Description: snapshot.Description ?? string.Empty);
}
/// <summary>True when this alarm is active and not yet acknowledged.</summary>
public bool IsUnacknowledged => State == AlarmConditionState.Active;
}
/// <summary>Result of a dashboard active-alarm query.</summary>
/// <param name="Alarms">The active alarms, or an empty list on error.</param>
/// <param name="Error">A diagnostic message when the query failed; otherwise null.</param>
/// <param name="WorkerProcessId">The worker process id backing the dashboard session, when available.</param>
public sealed record DashboardAlarmQueryResult(
IReadOnlyList<DashboardActiveAlarm> Alarms,
string? Error,
int? WorkerProcessId);
@@ -0,0 +1,95 @@
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Dashboard;
/// <summary>
/// One node in the dashboard Browse hierarchy tree. Wraps a Galaxy object
/// and its child objects; the object's attributes are the leaf "tags" the
/// operator can right-click and add to the subscription panel.
/// </summary>
public sealed class DashboardBrowseNode
{
/// <summary>The underlying Galaxy object for this node.</summary>
public required GalaxyObject Object { get; init; }
/// <summary>Child objects contained by this object, sorted areas-first then by name.</summary>
public List<DashboardBrowseNode> Children { get; } = [];
/// <summary>The label shown for this node in the tree.</summary>
public string DisplayName =>
!string.IsNullOrWhiteSpace(Object.BrowseName) ? Object.BrowseName
: !string.IsNullOrWhiteSpace(Object.ContainedName) ? Object.ContainedName
: Object.TagName;
/// <summary>True when this node is a Galaxy area rather than an instance object.</summary>
public bool IsArea => Object.IsArea;
/// <summary>The object's attributes — the browsable tags.</summary>
public IReadOnlyList<GalaxyAttribute> Attributes => Object.Attributes;
/// <summary>True when the node has child objects or attributes to expand.</summary>
public bool HasChildren => Children.Count > 0 || Object.Attributes.Count > 0;
}
/// <summary>
/// Builds the dashboard Browse tree from the flat Galaxy object list held
/// by <c>IGalaxyHierarchyCache</c>. Pure and side-effect free so the
/// parent/child linkage and ordering rules are unit-testable.
/// </summary>
public static class DashboardBrowseTreeBuilder
{
/// <summary>Builds the root nodes of the Browse tree.</summary>
/// <param name="objects">The flat Galaxy object list.</param>
/// <returns>The root nodes, sorted areas-first then alphabetically.</returns>
public static IReadOnlyList<DashboardBrowseNode> Build(IReadOnlyList<GalaxyObject> objects)
{
ArgumentNullException.ThrowIfNull(objects);
Dictionary<int, DashboardBrowseNode> nodes = new(objects.Count);
foreach (GalaxyObject galaxyObject in objects)
{
// Last write wins on a duplicate gobject id — Galaxy ids are unique
// in practice, but guard so the dictionary build never throws.
nodes[galaxyObject.GobjectId] = new DashboardBrowseNode { Object = galaxyObject };
}
List<DashboardBrowseNode> roots = [];
foreach (DashboardBrowseNode node in nodes.Values)
{
int parentId = node.Object.ParentGobjectId;
if (parentId != 0
&& parentId != node.Object.GobjectId
&& nodes.TryGetValue(parentId, out DashboardBrowseNode? parent))
{
parent.Children.Add(node);
}
else
{
roots.Add(node);
}
}
SortRecursive(roots);
return roots;
}
private static void SortRecursive(List<DashboardBrowseNode> nodes)
{
nodes.Sort(CompareNodes);
foreach (DashboardBrowseNode node in nodes)
{
SortRecursive(node.Children);
}
}
// Areas sort before instance objects; within a group, by display name.
private static int CompareNodes(DashboardBrowseNode left, DashboardBrowseNode right)
{
if (left.IsArea != right.IsArea)
{
return left.IsArea ? -1 : 1;
}
return string.Compare(left.DisplayName, right.DisplayName, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,231 @@
using MxGateway.Contracts.Proto;
using MxGateway.Server.Sessions;
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Default <see cref="IDashboardLiveDataService"/>. Owns one shared gateway
/// session for the whole dashboard: it is opened lazily on first use and
/// re-opened transparently whenever it faults, is closed, or its lease
/// expires. All access is serialised through <see cref="_gate"/> so the
/// single backing worker only ever sees one in-flight command.
/// </summary>
public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsyncDisposable
{
private const string BackendName = "Galaxy";
private const string ClientName = "mxgateway-dashboard";
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5);
private readonly ISessionManager _sessionManager;
private readonly IAlarmRpcDispatcher _alarmDispatcher;
private readonly ILogger<DashboardLiveDataService> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly HashSet<string> _subscribed = new(StringComparer.OrdinalIgnoreCase);
private GatewaySession? _session;
private int _serverHandle;
private bool _disposed;
/// <summary>Initializes the live-data service.</summary>
/// <param name="sessionManager">Gateway session manager.</param>
/// <param name="alarmDispatcher">Active-alarm query dispatcher.</param>
/// <param name="logger">Diagnostic logger.</param>
public DashboardLiveDataService(
ISessionManager sessionManager,
IAlarmRpcDispatcher alarmDispatcher,
ILogger<DashboardLiveDataService> logger)
{
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
_alarmDispatcher = alarmDispatcher ?? throw new ArgumentNullException(nameof(alarmDispatcher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<DashboardLiveReadResult> ReadAsync(
IReadOnlyCollection<string> tagAddresses,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(tagAddresses);
if (tagAddresses.Count == 0)
{
return DashboardLiveReadResult.Empty;
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
(GatewaySession session, int serverHandle) = await EnsureReadyAsync(cancellationToken)
.ConfigureAwait(false);
string[] toSubscribe = tagAddresses.Where(tag => !_subscribed.Contains(tag)).ToArray();
if (toSubscribe.Length > 0)
{
await session.SubscribeBulkAsync(serverHandle, toSubscribe, cancellationToken)
.ConfigureAwait(false);
foreach (string tag in toSubscribe)
{
_subscribed.Add(tag);
}
}
IReadOnlyList<BulkReadResult> results = await session
.ReadBulkAsync(serverHandle, tagAddresses.ToArray(), ReadTimeout, cancellationToken)
.ConfigureAwait(false);
DashboardTagValue[] values = results
.Select(DashboardTagValue.FromBulkReadResult)
.ToArray();
return new DashboardLiveReadResult(values, null, session.SessionId, session.WorkerProcessId);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
InvalidateSession();
_logger.LogWarning(exception, "Dashboard live read failed; the dashboard session will be re-opened.");
return new DashboardLiveReadResult([], exception.Message, null, null);
}
finally
{
_gate.Release();
}
}
/// <inheritdoc />
public async Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
(GatewaySession session, _) = await EnsureReadyAsync(cancellationToken).ConfigureAwait(false);
QueryActiveAlarmsRequest request = new()
{
SessionId = session.SessionId,
ClientCorrelationId = Guid.NewGuid().ToString("N"),
};
List<DashboardActiveAlarm> alarms = [];
await foreach (ActiveAlarmSnapshot snapshot in _alarmDispatcher
.QueryActiveAlarmsAsync(request, cancellationToken)
.ConfigureAwait(false))
{
alarms.Add(DashboardActiveAlarm.FromSnapshot(snapshot));
}
return new DashboardAlarmQueryResult(alarms, null, session.WorkerProcessId);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
InvalidateSession();
_logger.LogWarning(exception, "Dashboard alarm query failed; the dashboard session will be re-opened.");
return new DashboardAlarmQueryResult([], exception.Message, null);
}
finally
{
_gate.Release();
}
}
// Returns a Ready session + its Register server handle, opening a fresh
// session when none exists or the current one is no longer usable. Callers
// must hold _gate.
private async Task<(GatewaySession Session, int ServerHandle)> EnsureReadyAsync(
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
GatewaySession? existing = _session;
if (existing is not null
&& existing.State == SessionState.Ready
&& _sessionManager.TryGetSession(existing.SessionId, out _))
{
return (existing, _serverHandle);
}
if (existing is not null)
{
_logger.LogInformation(
"Dashboard session {SessionId} is no longer usable (state {State}); re-opening.",
existing.SessionId,
existing.State);
await CloseQuietlyAsync(existing.SessionId).ConfigureAwait(false);
}
_subscribed.Clear();
_session = null;
GatewaySession session = await _sessionManager.OpenSessionAsync(
new SessionOpenRequest(BackendName, ClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null),
ClientName,
cancellationToken)
.ConfigureAwait(false);
WorkerCommandReply reply = await session.InvokeAsync(
new WorkerCommand
{
Command = new MxCommand
{
Kind = MxCommandKind.Register,
Register = new RegisterCommand { ClientName = ClientName },
},
},
cancellationToken)
.ConfigureAwait(false);
int? serverHandle = reply.Reply?.Register?.ServerHandle;
if (serverHandle is null)
{
string diagnostic = reply.Reply?.ProtocolStatus?.Message
?? reply.Reply?.DiagnosticMessage
?? "Worker did not return a server handle for Register.";
await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false);
throw new InvalidOperationException($"Dashboard session registration failed: {diagnostic}");
}
_session = session;
_serverHandle = serverHandle.Value;
_logger.LogInformation(
"Dashboard session {SessionId} opened (worker pid {WorkerPid}).",
session.SessionId,
session.WorkerProcessId);
return (session, _serverHandle);
}
// Drops the cached session so the next call re-opens. Callers must hold _gate.
private void InvalidateSession()
{
_session = null;
_serverHandle = 0;
_subscribed.Clear();
}
private async Task CloseQuietlyAsync(string sessionId)
{
try
{
await _sessionManager.CloseSessionAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "Closing stale dashboard session {SessionId} failed.", sessionId);
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
GatewaySession? session = _session;
_session = null;
if (session is not null)
{
await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false);
}
_gate.Dispose();
}
}
@@ -0,0 +1,53 @@
using System.Globalization;
using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Formats an <see cref="MxValue"/> into the short, human-readable text the
/// dashboard's Browse subscription panel shows. Kept separate from the
/// view layer so the formatting rules are unit-testable without a worker.
/// </summary>
public static class DashboardMxValueFormatter
{
/// <summary>Formats the value payload of an <see cref="MxValue"/>.</summary>
/// <param name="value">The value to format; may be null.</param>
/// <returns>A display string — never null.</returns>
public static string FormatValue(MxValue? value)
{
if (value is null)
{
return "-";
}
if (value.IsNull)
{
return "(null)";
}
return value.KindCase switch
{
MxValue.KindOneofCase.BoolValue => value.BoolValue ? "true" : "false",
MxValue.KindOneofCase.Int32Value => value.Int32Value.ToString(CultureInfo.InvariantCulture),
MxValue.KindOneofCase.Int64Value => value.Int64Value.ToString(CultureInfo.InvariantCulture),
MxValue.KindOneofCase.FloatValue => value.FloatValue.ToString("G7", CultureInfo.InvariantCulture),
MxValue.KindOneofCase.DoubleValue => value.DoubleValue.ToString("G15", CultureInfo.InvariantCulture),
MxValue.KindOneofCase.StringValue => value.StringValue,
MxValue.KindOneofCase.TimestampValue => value.TimestampValue
.ToDateTimeOffset()
.UtcDateTime
.ToString("yyyy-MM-dd HH:mm:ss.fff 'UTC'", CultureInfo.InvariantCulture),
MxValue.KindOneofCase.ArrayValue => "(array)",
MxValue.KindOneofCase.RawValue => $"({value.RawValue.Length} bytes)",
_ => "-",
};
}
/// <summary>Formats the MXAccess data type of an <see cref="MxValue"/>.</summary>
/// <param name="value">The value whose data type to describe; may be null.</param>
/// <returns>The data-type name — never null.</returns>
public static string FormatDataType(MxValue? value)
{
return value is null ? "-" : value.DataType.ToString();
}
}
@@ -17,6 +17,7 @@ public static class DashboardServiceCollectionExtensions
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
{
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
services.AddSingleton<DashboardApiKeyAuthorization>();
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
@@ -0,0 +1,69 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Dashboard;
/// <summary>
/// One live tag value as shown in the Browse subscription panel. Projected
/// from a worker <see cref="BulkReadResult"/> so the Razor component never
/// touches protobuf types directly.
/// </summary>
public sealed record DashboardTagValue(
string TagAddress,
bool Ok,
string ValueText,
string DataType,
int Quality,
bool QualityGood,
DateTimeOffset? SourceTimestamp,
string? Error)
{
/// <summary>
/// Classic OPC-DA "Good" quality. MXAccess surfaces 192 for a healthy
/// advised value; anything lower is uncertain or bad.
/// </summary>
private const int GoodQualityThreshold = 192;
/// <summary>Projects a worker bulk-read result into a dashboard tag value.</summary>
/// <param name="result">The per-tag result from a <c>ReadBulk</c> reply.</param>
/// <returns>The projected dashboard value.</returns>
public static DashboardTagValue FromBulkReadResult(BulkReadResult result)
{
ArgumentNullException.ThrowIfNull(result);
string? error = null;
if (!result.WasSuccessful)
{
error = !string.IsNullOrWhiteSpace(result.ErrorMessage)
? result.ErrorMessage
: FirstStatusDiagnostic(result);
}
return new DashboardTagValue(
TagAddress: result.TagAddress,
Ok: result.WasSuccessful,
ValueText: DashboardMxValueFormatter.FormatValue(result.Value),
DataType: DashboardMxValueFormatter.FormatDataType(result.Value),
Quality: result.Quality,
QualityGood: result.WasSuccessful && result.Quality >= GoodQualityThreshold,
SourceTimestamp: result.SourceTimestamp?.ToDateTimeOffset(),
Error: error);
}
private static string? FirstStatusDiagnostic(BulkReadResult result)
{
foreach (MxStatusProxy status in result.Statuses)
{
if (!string.IsNullOrWhiteSpace(status.DiagnosticText))
{
return status.DiagnosticText;
}
if (status.Category != MxStatusCategory.Ok)
{
return status.Category.ToString();
}
}
return null;
}
}
@@ -0,0 +1,47 @@
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Supplies the Browse and Alarms dashboard tabs with live MXAccess data.
/// Owns one shared, lazily-opened gateway session (and therefore one
/// worker process) used by every dashboard circuit; the session is
/// transparently re-opened if it faults or its lease expires.
/// </summary>
public interface IDashboardLiveDataService
{
/// <summary>
/// Subscribes (once) and reads the current value, quality and timestamp
/// of the supplied tags. Never throws — transport and session failures
/// are surfaced in <see cref="DashboardLiveReadResult.Error"/>.
/// </summary>
/// <param name="tagAddresses">Fully-qualified tag references to read.</param>
/// <param name="cancellationToken">Token to cancel the read.</param>
/// <returns>The read result, or an error-bearing result on failure.</returns>
Task<DashboardLiveReadResult> ReadAsync(
IReadOnlyCollection<string> tagAddresses,
CancellationToken cancellationToken);
/// <summary>
/// Queries the currently-active alarm set for the dashboard session.
/// Never throws — failures are surfaced in
/// <see cref="DashboardAlarmQueryResult.Error"/>.
/// </summary>
/// <param name="cancellationToken">Token to cancel the query.</param>
/// <returns>The active alarms, or an error-bearing result on failure.</returns>
Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken);
}
/// <summary>Result of a dashboard live tag read.</summary>
/// <param name="Values">The per-tag values, or an empty list on error.</param>
/// <param name="Error">A diagnostic message when the read failed; otherwise null.</param>
/// <param name="SessionId">The dashboard session id used, when available.</param>
/// <param name="WorkerProcessId">The worker process id backing the session, when available.</param>
public sealed record DashboardLiveReadResult(
IReadOnlyList<DashboardTagValue> Values,
string? Error,
string? SessionId,
int? WorkerProcessId)
{
/// <summary>An empty, successful result — used when no tags are subscribed.</summary>
public static DashboardLiveReadResult Empty { get; } =
new([], null, null, null);
}
@@ -306,3 +306,213 @@ code {
}
.details-table th { width: 9rem; }
}
/* ── Browse tab ───────────────────────────────────────────────────────────────
Two-pane layout: the Galaxy hierarchy tree on the left, the live
subscription panel on the right. Both panes are .dashboard-section cards. */
.browse-layout {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
align-items: start;
}
.browse-panel { margin-top: 0; }
.browse-search { margin-bottom: 0.6rem; }
.browse-search-note {
margin-top: 0.5rem;
font-size: 0.74rem;
color: var(--ink-faint);
}
.browse-tree {
max-height: 32rem;
overflow-y: auto;
border: 1px solid var(--rule);
border-radius: 6px;
padding: 0.3rem 0;
background: #fbfbf9;
}
.browse-search-results { padding: 0.15rem 0; }
.tree-children { margin-left: 0.95rem; border-left: 1px solid var(--rule); }
.tree-row,
.tree-attr {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.12rem 0.5rem;
font-size: 0.82rem;
white-space: nowrap;
}
.tree-row:hover,
.tree-attr:hover { background: #eef2fb; }
.tree-toggle {
flex: none;
width: 1.1rem;
height: 1.1rem;
line-height: 1;
padding: 0;
border: none;
background: transparent;
color: var(--ink-faint);
font-size: 0.7rem;
cursor: pointer;
}
.tree-toggle-empty { cursor: default; }
.tree-label {
display: flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-icon { color: var(--accent); font-size: 0.72rem; }
.tree-name { color: var(--ink); }
.tree-row-area .tree-name { font-weight: 600; }
.tree-tag { font-size: 0.72rem; color: var(--ink-faint); }
.tree-attr { cursor: context-menu; }
.attr-icon { flex: none; color: var(--ink-faint); width: 0.6rem; text-align: center; }
.attr-name { font-family: var(--mono); color: var(--ink); }
.attr-type { font-size: 0.72rem; color: var(--ink-faint); }
.attr-flag {
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.02rem 0.28rem;
border-radius: 3px;
}
.attr-flag-alarm { color: var(--bad); background: var(--bad-bg); }
.attr-flag-hist { color: var(--accent-deep); background: #e7ecfb; }
/* ── Subscription panel ───────────────────────────────────────────────────── */
.sub-panel-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.76rem;
color: var(--ink-faint);
margin-bottom: 0.5rem;
min-height: 1.6rem;
}
.sub-clear { margin-left: auto; }
.sub-table td { vertical-align: middle; }
.sub-value {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
color: var(--ink);
font-weight: 600;
}
.sub-actions-col { width: 1%; white-space: nowrap; }
.quality-chip {
font-family: var(--mono);
font-size: 0.74rem;
font-weight: 600;
padding: 0.05rem 0.4rem;
border-radius: 3px;
}
.quality-good { color: var(--ok); background: var(--ok-bg); }
.quality-bad { color: var(--bad); background: var(--bad-bg); }
.sub-error {
margin-left: 0.3rem;
color: var(--bad);
font-weight: 700;
cursor: help;
}
/* ── Right-click context menu ─────────────────────────────────────────────── */
.context-menu-overlay {
position: fixed;
inset: 0;
z-index: 1040;
background: transparent;
}
.context-menu {
position: fixed;
z-index: 1050;
min-width: 13rem;
background: var(--card);
border: 1px solid var(--rule-strong);
border-radius: 6px;
box-shadow: 0 6px 20px rgba(27, 29, 33, 0.16);
padding: 0.25rem;
}
.context-menu-head {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-faint);
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--rule);
margin-bottom: 0.2rem;
overflow: hidden;
text-overflow: ellipsis;
}
.context-menu-item {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
color: var(--ink);
font-size: 0.83rem;
padding: 0.4rem 0.5rem;
border-radius: 4px;
cursor: pointer;
}
.context-menu-item:hover { background: #eef2fb; color: var(--accent-deep); }
/* ── Alarms tab ───────────────────────────────────────────────────────────── */
.alarm-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem 1rem;
}
.alarm-filter-check {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.82rem;
color: var(--ink-soft);
}
.alarm-filter-field { display: flex; flex-direction: column; gap: 0.2rem; }
.alarm-filter-field input[type="number"] { width: 7rem; }
.alarm-filter-grow { flex: 1 1 14rem; }
.alarm-filter-grow input { width: 100%; }
.alarm-state {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.1rem 0.45rem;
border-radius: 3px;
white-space: nowrap;
}
.alarm-state-active { color: var(--bad); background: var(--bad-bg); }
.alarm-state-acked { color: var(--warn); background: var(--warn-bg); }
.alarm-state-other { color: var(--idle); background: var(--idle-bg); }
.alarm-severity {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.alarm-desc {
margin-top: 0.1rem;
font-size: 0.74rem;
color: var(--ink-faint);
}
@media (max-width: 960px) {
.browse-layout { grid-template-columns: minmax(0, 1fr); }
}
@@ -0,0 +1,129 @@
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Dashboard;
namespace MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Unit tests for the pure projection/formatting helpers behind the
/// dashboard Browse and Alarms tabs.
/// </summary>
public sealed class DashboardBrowseAndAlarmModelTests
{
[Fact]
public void BuildTree_LinksChildrenToParents_AndPromotesOrphansToRoots()
{
GalaxyObject area = new() { GobjectId = 1, BrowseName = "AreaA", IsArea = true, ParentGobjectId = 0 };
GalaxyObject child = new() { GobjectId = 2, BrowseName = "Pump01", ParentGobjectId = 1 };
GalaxyObject orphan = new() { GobjectId = 3, BrowseName = "Lost", ParentGobjectId = 99 };
IReadOnlyList<DashboardBrowseNode> roots = DashboardBrowseTreeBuilder.Build([area, child, orphan]);
// The area and the orphan (its parent id is absent) are both roots.
Assert.Equal(2, roots.Count);
DashboardBrowseNode areaNode = Assert.Single(roots, node => node.Object.GobjectId == 1);
Assert.Single(areaNode.Children);
Assert.Equal(2, areaNode.Children[0].Object.GobjectId);
Assert.Contains(roots, node => node.Object.GobjectId == 3);
}
[Fact]
public void BuildTree_SortsAreasBeforeObjects()
{
GalaxyObject instance = new() { GobjectId = 1, BrowseName = "Zeta", IsArea = false };
GalaxyObject areaB = new() { GobjectId = 2, BrowseName = "Beta", IsArea = true };
IReadOnlyList<DashboardBrowseNode> roots = DashboardBrowseTreeBuilder.Build([instance, areaB]);
Assert.Equal(2, roots.Count);
Assert.True(roots[0].IsArea);
Assert.Equal("Beta", roots[0].DisplayName);
Assert.False(roots[1].IsArea);
}
[Theory]
[InlineData(true, "true")]
[InlineData(false, "false")]
public void FormatValue_FormatsBooleans(bool input, string expected)
{
MxValue value = new() { DataType = MxDataType.Boolean, BoolValue = input };
Assert.Equal(expected, DashboardMxValueFormatter.FormatValue(value));
}
[Fact]
public void FormatValue_FormatsNumbersAndStrings()
{
Assert.Equal("42", DashboardMxValueFormatter.FormatValue(new MxValue { Int32Value = 42 }));
Assert.Equal("hello", DashboardMxValueFormatter.FormatValue(new MxValue { StringValue = "hello" }));
}
[Fact]
public void FormatValue_HandlesNullPayloadAndNullReference()
{
Assert.Equal("-", DashboardMxValueFormatter.FormatValue(null));
Assert.Equal("(null)", DashboardMxValueFormatter.FormatValue(new MxValue { IsNull = true }));
}
[Fact]
public void TagValue_FromSuccessfulReadResult_MarksGoodQuality()
{
BulkReadResult result = new()
{
TagAddress = "Galaxy!Area.Tag",
WasSuccessful = true,
Quality = 192,
Value = new MxValue { DataType = MxDataType.Double, DoubleValue = 1.5 },
};
DashboardTagValue value = DashboardTagValue.FromBulkReadResult(result);
Assert.True(value.Ok);
Assert.True(value.QualityGood);
Assert.Equal("1.5", value.ValueText);
Assert.Null(value.Error);
}
[Fact]
public void TagValue_FromFailedReadResult_CarriesError()
{
BulkReadResult result = new()
{
TagAddress = "Galaxy!Area.Bad",
WasSuccessful = false,
Quality = 0,
ErrorMessage = "invalid handle",
};
DashboardTagValue value = DashboardTagValue.FromBulkReadResult(result);
Assert.False(value.Ok);
Assert.False(value.QualityGood);
Assert.Equal("invalid handle", value.Error);
}
[Fact]
public void ActiveAlarm_FromSnapshot_ParsesProviderAndAcknowledgementState()
{
ActiveAlarmSnapshot unacked = new()
{
AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
Category = "TestArea",
CurrentState = AlarmConditionState.Active,
Severity = 500,
};
ActiveAlarmSnapshot acked = new()
{
AlarmFullReference = "Galaxy!TestArea.TestMachine_002.TestAlarm001",
CurrentState = AlarmConditionState.ActiveAcked,
};
DashboardActiveAlarm unackedRow = DashboardActiveAlarm.FromSnapshot(unacked);
DashboardActiveAlarm ackedRow = DashboardActiveAlarm.FromSnapshot(acked);
Assert.Equal("Galaxy", unackedRow.Provider);
Assert.Equal("TestArea", unackedRow.Area);
Assert.Equal(500, unackedRow.Severity);
Assert.True(unackedRow.IsUnacknowledged);
Assert.False(ackedRow.IsUnacknowledged);
}
}