Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor
T

502 lines
17 KiB
Plaintext

@page "/browse"
@implements IAsyncDisposable
@inject IGalaxyHierarchyCache GalaxyCache
@inject IDashboardLiveDataService LiveData
@inject IDashboardBrowseService BrowseService
@inject IGalaxyDeployNotifier DeployNotifier
@using ZB.MOM.WW.GalaxyRepository
@using ZB.MOM.WW.GalaxyRepository.Grpc
<PageTitle>Dashboard Browse</PageTitle>
<div class="dashboard-page-header">
<div>
<h1>Browse</h1>
<div class="text-secondary">@HeaderLine()</div>
</div>
<StatusBadge Text="@GalaxyCache.Current.Status.ToString()" />
</div>
<div class="browse-layout">
<section class="dashboard-section browse-panel">
<div class="section-heading">
<h2>Galaxy Hierarchy</h2>
</div>
<input class="form-control form-control-sm browse-search"
placeholder="Filter attributes by name or reference…"
@bind="Search"
@bind:event="oninput" />
@if (_roots.Count == 0)
{
<div class="empty-state">
No Galaxy hierarchy is cached yet. The hierarchy refreshes from the
Galaxy Repository in the background — check the Galaxy tab for status.
</div>
}
else if (!string.IsNullOrWhiteSpace(Search))
{
@if (_searchMatches.Count == 0)
{
<div class="empty-state">No attributes match &ldquo;@Search&rdquo;.</div>
}
else
{
<div class="browse-tree browse-search-results">
@foreach (GalaxyAttribute hit in _searchMatches)
{
GalaxyAttribute row = hit;
<div class="tree-attr"
title="@row.FullTagReference"
@oncontextmenu:preventDefault="true"
@oncontextmenu="@(args => ShowMenu(args, row))"
@ondblclick="@(() => AddTagAsync(row.FullTagReference))">
<span class="attr-icon">·</span>
<span class="attr-name">@row.FullTagReference</span>
<span class="attr-type">@FormatType(row)</span>
@if (row.IsAlarm)
{
<span class="attr-flag attr-flag-alarm">alarm</span>
}
@if (row.IsHistorized)
{
<span class="attr-flag attr-flag-hist">hist</span>
}
</div>
}
</div>
@if (_searchMatches.Count >= SearchResultLimit)
{
<div class="browse-search-note">Showing the first @SearchResultLimit matches — refine the filter.</div>
}
}
}
else
{
@if (!string.IsNullOrEmpty(_staleBanner))
{
<div class="alert alert-info browse-stale-banner" role="status"
@onclick="ClearStaleBanner">@_staleBanner</div>
}
<div class="browse-tree">
@foreach (DashboardBrowseNode root in _roots)
{
<BrowseTreeNodeView Node="root"
OnAddTag="AddTagAsync"
OnTagContextMenu="OnTagContextMenu"
OnLoadChildren="LoadChildrenAsync" />
}
</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 List<DashboardBrowseNode> _roots = [];
private ulong _cacheSequence;
private string? _staleBanner;
private CancellationTokenSource _deployCts = new();
private Task? _deployTask;
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()
{
BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs());
_roots = [.. roots.Nodes];
_cacheSequence = roots.CacheSequence;
_pollTask = PollLoopAsync();
_deployTask = SubscribeToDeployEventsAsync();
}
private async Task LoadChildrenAsync(DashboardBrowseNode node)
{
BrowseLevelResult result = BrowseService.GetChildren(node.Object.GobjectId, new BrowseFilterArgs());
if (!string.IsNullOrEmpty(result.Error))
{
throw new InvalidOperationException(result.Error);
}
node.Children.Clear();
foreach (DashboardBrowseNode child in result.Nodes)
{
node.Children.Add(child);
}
// First expand interaction also dismisses the stale banner — the user
// is clearly engaging with the tree, no need to keep nagging.
_staleBanner = null;
await InvokeAsync(StateHasChanged);
}
private async Task SubscribeToDeployEventsAsync()
{
try
{
await foreach (GalaxyDeployEventInfo info in DeployNotifier
.SubscribeAsync(_deployCts.Token)
.ConfigureAwait(false))
{
// First Latest replay echoes the sequence we already projected
// from — skip those to avoid a spurious "redeployed" banner.
if (info.Sequence == 0 || (ulong)info.Sequence == _cacheSequence)
{
continue;
}
BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs());
_roots = [.. roots.Nodes];
_cacheSequence = roots.CacheSequence;
_staleBanner = "Galaxy redeployed — tree refreshed.";
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
}
private void ClearStaleBanner()
{
_staleBanner = null;
}
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();
await _deployCts.CancelAsync();
if (_pollTask is not null)
{
try
{
await _pollTask;
}
catch (OperationCanceledException)
{
}
}
if (_deployTask is not null)
{
try
{
await _deployTask;
}
catch (OperationCanceledException)
{
}
}
_cts.Dispose();
_deployCts.Dispose();
GC.SuppressFinalize(this);
}
}