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:
Joseph Doherty
2026-05-23 16:22:23 -04:00
parent 867bf18116
commit dc9c0c950c
491 changed files with 32854 additions and 8414 deletions
@@ -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 &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
{
<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);
}
}