502 lines
17 KiB
Plaintext
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 “@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
|
|
{
|
|
@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);
|
|
}
|
|
}
|