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
|
Do not display full tag values by default. If value display is later added, make
|
||||||
it opt-in and redacted.
|
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
|
### API keys page
|
||||||
|
|
||||||
`/dashboard/apikeys` lists the gateway's API keys and, for authorized
|
`/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
|
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
|
||||||
effective configuration into immutable DTOs for read-only dashboard rendering.
|
effective configuration into immutable DTOs for read-only dashboard rendering.
|
||||||
The Blazor Server dashboard renders those snapshots at `/dashboard`,
|
The Blazor Server dashboard renders those snapshots at `/dashboard`,
|
||||||
`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`, and
|
`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`,
|
||||||
`/dashboard/settings`. Components subscribe to
|
`/dashboard/galaxy`, and `/dashboard/settings`. Components subscribe to
|
||||||
`IDashboardSnapshotService.WatchSnapshotsAsync()` and update on the configured
|
`IDashboardSnapshotService.WatchSnapshotsAsync()` and update on the configured
|
||||||
snapshot interval without mutating session or worker state. The dashboard uses
|
snapshot interval without mutating session or worker state. The dashboard uses
|
||||||
local Bootstrap CSS and JavaScript plus a small local stylesheet; it does not
|
local Bootstrap CSS and JavaScript plus a small local stylesheet; it does not
|
||||||
use a Blazor UI component library.
|
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`
|
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,
|
accepts the API key in a form body, validates the configured `admin` scope,
|
||||||
and issues an HTTP-only secure cookie for subsequent dashboard requests.
|
and issues an HTTP-only secure cookie for subsequent dashboard requests.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
<NavLink href="workers">Workers</NavLink>
|
<NavLink href="workers">Workers</NavLink>
|
||||||
<NavLink href="events">Events</NavLink>
|
<NavLink href="events">Events</NavLink>
|
||||||
<NavLink href="galaxy">Galaxy</NavLink>
|
<NavLink href="galaxy">Galaxy</NavLink>
|
||||||
|
<NavLink href="browse">Browse</NavLink>
|
||||||
|
<NavLink href="alarms">Alarms</NavLink>
|
||||||
<NavLink href="apikeys">API Keys</NavLink>
|
<NavLink href="apikeys">API Keys</NavLink>
|
||||||
<NavLink href="settings">Settings</NavLink>
|
<NavLink href="settings">Settings</NavLink>
|
||||||
</nav>
|
</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)
|
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||||
|
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
|
||||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
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; }
|
.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