@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 Dashboard Browse

Browse

@HeaderLine()

Galaxy Hierarchy

@if (_roots.Count == 0) {
No Galaxy hierarchy is cached yet. The hierarchy refreshes from the Galaxy Repository in the background — check the Galaxy tab for status.
} else if (!string.IsNullOrWhiteSpace(Search)) { @if (_searchMatches.Count == 0) {
No attributes match “@Search”.
} else {
@foreach (GalaxyAttribute hit in _searchMatches) { GalaxyAttribute row = hit;
· @row.FullTagReference @FormatType(row) @if (row.IsAlarm) { alarm } @if (row.IsHistorized) { hist }
}
@if (_searchMatches.Count >= SearchResultLimit) {
Showing the first @SearchResultLimit matches — refine the filter.
} } } else { @if (!string.IsNullOrEmpty(_staleBanner)) {
@_staleBanner
}
@foreach (DashboardBrowseNode root in _roots) { }
Double-click a tag, or right-click for the menu.
}

Subscription Panel

@if (_subscribed.Count > 0) { @_subscribed.Count subscribed · refresh 2s @if (_workerPid is int pid) { · worker pid @pid } }
@if (!string.IsNullOrWhiteSpace(_readError)) {
Live read failed: @_readError
} @if (_subscribed.Count == 0) {
No tags subscribed. Right-click a tag in the hierarchy and choose Add to subscription panel (or double-click it) to watch its live value, quality and source timestamp here.
} else {
@foreach (string tag in _subscribed) { string key = tag; DashboardTagValue? value = _values.GetValueOrDefault(key); }
Tag Value Type Quality Updated
@key @(value?.ValueText ?? "…") @(value?.DataType ?? "-") @if (value is null) { } else { @value.Quality @if (!string.IsNullOrWhiteSpace(value.Error)) { ! } } @TimestampText(key, value)
}
@if (_menuVisible) {
@(_menuAttribute?.AttributeName)
} @code { private const int SearchResultLimit = 300; private List _roots = []; private ulong _cacheSequence; private string? _staleBanner; private CancellationTokenSource _deployCts = new(); private Task? _deployTask; private string _search = string.Empty; private IReadOnlyList _searchMatches = []; private readonly List _subscribed = []; private readonly Dictionary _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 _valueSignature = new(StringComparer.Ordinal); private readonly Dictionary _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; /// 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 ComputeSearch(string rawQuery) { string query = rawQuery.Trim(); if (query.Length == 0) { return []; } List 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(); } /// 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); } }