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:
@@ -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
@@ -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 “@Search”.</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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user