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
+43
View File
@@ -254,6 +254,49 @@ Show aggregate event diagnostics:
Do not display full tag values by default. If value display is later added, make
it opt-in and redacted.
### Browse page
`/dashboard/browse` lets an operator explore the Galaxy tag hierarchy and watch
live values. The tree is built in-process by `DashboardBrowseTreeBuilder` from
`IGalaxyHierarchyCache.Current` — the same cache the Galaxy page reads — so a
render costs no gRPC call and no SQL round-trip. Each node shows its child
objects and, when expanded, its attributes with attribute name, data type
(including array dimension), and the alarm / historized flags. Galaxy SQL
carries no attribute description, so none is shown. A filter box switches the
tree to a flat list of matching attributes.
Right-clicking an attribute (or double-clicking it) adds it to the subscription
panel. The panel shows each subscribed tag's live value, MXAccess data type,
quality and source timestamp, refreshed every two seconds. The subscription
panel is the explicit opt-in tag-value surface: it always shows values
regardless of `Dashboard:ShowTagValues`, which continues to govern only the
diagnostic session/worker views.
### Alarms page
`/dashboard/alarms` lists the alarms the dashboard session's worker currently
reports as Active or ActiveAcked, refreshed every three seconds. It defaults to
showing unacknowledged `Active` alarms; filters add acknowledged alarms and
narrow by area, severity range, and a reference/source/description text search.
Cleared alarms are not retained — the gateway holds no alarm-history store, so
the page reflects only the live active set. The page is read-only; it does not
acknowledge alarms. If `MxGateway:Alarms:Enabled` is false the session is never
subscribed to an alarm provider, and the page says so instead of showing an
empty list with no explanation.
### Live data source
Both the Browse subscription panel and the Alarms page read live MXAccess data
through `IDashboardLiveDataService` (`DashboardLiveDataService`). It owns one
shared gateway session for the whole dashboard, opened lazily on first use via
`ISessionManager` and re-opened transparently when it faults or its lease
expires. One session means one worker process backs every dashboard circuit;
all access is serialised so the worker sees one in-flight command at a time.
Tag reads go through `GatewaySession.SubscribeBulkAsync` / `ReadBulkAsync`;
alarm queries go through `IAlarmRpcDispatcher`. Alarm subscription is the
gateway's existing auto-subscribe-on-open hook, so the dashboard session is
alarm-subscribed only when `MxGateway:Alarms:Enabled` is set.
### API keys page
`/dashboard/apikeys` lists the gateway's API keys and, for authorized
+9 -2
View File
@@ -113,13 +113,20 @@ project without binding to a metrics exporter.
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
effective configuration into immutable DTOs for read-only dashboard rendering.
The Blazor Server dashboard renders those snapshots at `/dashboard`,
`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`, and
`/dashboard/settings`. Components subscribe to
`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`,
`/dashboard/galaxy`, and `/dashboard/settings`. Components subscribe to
`IDashboardSnapshotService.WatchSnapshotsAsync()` and update on the configured
snapshot interval without mutating session or worker state. The dashboard uses
local Bootstrap CSS and JavaScript plus a small local stylesheet; it does not
use a Blazor UI component library.
`/dashboard/browse` and `/dashboard/alarms` go beyond read-only snapshots: they
read live MXAccess data through `IDashboardLiveDataService`, which owns one
shared, lazily-opened gateway session (and therefore one worker) for the whole
dashboard. Browse walks the `IGalaxyHierarchyCache` tree and reads subscribed
tag values; Alarms lists the worker's currently-active alarm set. See
`docs/GatewayDashboardDesign.md`.
Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login`
accepts the API key in a form body, validates the configured `admin` scope,
and issues an HTTP-only secure cookie for subsequent dashboard requests.
@@ -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);
}
}