rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.
External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths
Also fixes two tests that were not rename-related but became visible
while validating the rename:
- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
gateway service correctly maps to RpcException(Cancelled) per gRPC
convention was being misclassified as a stream fault. Added a sibling
catch on RpcException with StatusCode.Cancelled.
- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
and made it accept either a .git marker OR a .sln/.slnx next to src/
so the worker-exe walker works in non-git working copies.
clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.
Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
Tests: 472/472 pass
Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
IntegrationTests: 18/18 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
@page "/browse"
|
||||
@page "/dashboard/browse"
|
||||
@implements IAsyncDisposable
|
||||
@inject IGalaxyHierarchyCache GalaxyCache
|
||||
@inject IDashboardLiveDataService LiveData
|
||||
@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy
|
||||
@using ZB.MOM.WW.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user