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,35 @@
@inject IOptions<GatewayOptions> GatewayOptions
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="@DashboardBaseHref" />
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/dashboard.css" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body class="dashboard-body">
<Routes @rendermode="InteractiveServer" />
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/_framework/blazor.web.js"></script>
</body>
</html>
@code {
private string DashboardBaseHref
{
get
{
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase))
{
pathBase = "/dashboard";
}
return $"{pathBase}/";
}
}
}
@@ -0,0 +1,62 @@
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
public static class DashboardDisplay
{
/// <summary>
/// Formats a nullable date and time value for display.
/// </summary>
/// <param name="value">The date and time to format.</param>
/// <returns>Formatted date and time string or "-" if null.</returns>
public static string DateTime(DateTimeOffset? value)
{
return value.HasValue
? value.Value.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", System.Globalization.CultureInfo.InvariantCulture)
: "-";
}
/// <summary>
/// Formats a time span duration for display.
/// </summary>
/// <param name="value">The duration to format.</param>
/// <returns>Formatted duration string.</returns>
public static string Duration(TimeSpan value)
{
return value.TotalDays >= 1
? value.ToString(@"d\.hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture)
: value.ToString(@"hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture);
}
/// <summary>
/// Formats a nullable text value for display.
/// </summary>
/// <param name="value">The text to format.</param>
/// <returns>Formatted text or "-" if null or empty.</returns>
public static string Text(string? value)
{
return string.IsNullOrWhiteSpace(value) ? "-" : value;
}
/// <summary>
/// Formats a long count value for display with thousands separator.
/// </summary>
/// <param name="value">The count to format.</param>
/// <returns>Formatted count string.</returns>
public static string Count(long value)
{
return value.ToString("N0", System.Globalization.CultureInfo.InvariantCulture);
}
/// <summary>
/// Retrieves a metric value from a snapshot by name and optional dimension.
/// </summary>
/// <param name="snapshot">Dashboard snapshot.</param>
/// <param name="name">Metric name.</param>
/// <param name="dimension">Optional metric dimension.</param>
/// <returns>Metric value or zero if not found.</returns>
public static long MetricValue(DashboardSnapshot snapshot, string name, string? dimension = null)
{
return snapshot.Metrics.FirstOrDefault(metric =>
string.Equals(metric.Name, name, StringComparison.Ordinal)
&& string.Equals(metric.Dimension, dimension, StringComparison.Ordinal))?.Value ?? 0;
}
}
@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Components;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
/// <summary>
/// Base class for Blazor dashboard pages that watch gateway metrics snapshots.
/// </summary>
public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
{
private readonly CancellationTokenSource _disposeCancellation = new();
private Task? _watchTask;
/// <summary>
/// Service that provides gateway metric snapshots.
/// </summary>
[Inject]
protected IDashboardSnapshotService SnapshotService { get; set; } = null!;
/// <summary>
/// The most recent gateway metric snapshot, updated as it changes.
/// </summary>
protected DashboardSnapshot? Snapshot { get; private set; }
/// <inheritdoc />
protected override void OnInitialized()
{
_watchTask = WatchSnapshotsAsync();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _disposeCancellation.CancelAsync().ConfigureAwait(false);
if (_watchTask is not null)
{
await _watchTask.ConfigureAwait(false);
}
_disposeCancellation.Dispose();
GC.SuppressFinalize(this);
}
/// <summary>
/// Watches snapshot changes and triggers component refresh.
/// </summary>
private async Task WatchSnapshotsAsync()
{
try
{
await foreach (DashboardSnapshot snapshot in SnapshotService
.WatchSnapshotsAsync(_disposeCancellation.Token)
.ConfigureAwait(false))
{
Snapshot = snapshot;
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (_disposeCancellation.IsCancellationRequested)
{
}
}
}
@@ -0,0 +1,50 @@
@inherits LayoutComponentBase
@inject IOptions<GatewayOptions> GatewayOptions
<div class="dashboard-shell">
<header class="app-bar">
<a class="brand" href=""><span class="mark">&#9646;</span> MXAccess Gateway</a>
<nav class="app-nav">
<NavLink href="" Match="NavLinkMatch.All">Overview</NavLink>
<NavLink href="sessions">Sessions</NavLink>
<NavLink href="workers">Workers</NavLink>
<NavLink href="events">Events</NavLink>
<NavLink href="galaxy">Galaxy</NavLink>
<NavLink href="browse">Browse</NavLink>
<NavLink href="alarms">Alarms</NavLink>
<NavLink href="apikeys">API Keys</NavLink>
<NavLink href="settings">Settings</NavLink>
</nav>
<span class="spacer"></span>
<AuthorizeView>
<Authorized Context="authState">
<div class="app-user">
<span class="meta">@authState.User.Identity?.Name</span>
<form method="post" action="@DashboardPath("/logout")">
<AntiforgeryToken />
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</header>
<main class="page">
@Body
</main>
</div>
@code {
private string DashboardPath(string relativePath)
{
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase))
{
pathBase = "/dashboard";
}
return $"{pathBase}{relativePath}";
}
}
@@ -0,0 +1,286 @@
@page "/alarms"
@page "/dashboard/alarms"
@implements IAsyncDisposable
@inject IDashboardLiveDataService LiveData
@inject IOptions<GatewayOptions> GatewayOptions
<PageTitle>Dashboard Alarms</PageTitle>
<div class="dashboard-page-header">
<div>
<h1>Alarms</h1>
<div class="text-secondary">@HeaderLine()</div>
</div>
</div>
@if (!GatewayOptions.Value.Alarms.Enabled)
{
<div class="alert alert-danger">
Alarm auto-subscribe is disabled (<code>MxGateway:Alarms:Enabled</code> is false). The
dashboard session is not subscribed to any alarm provider, so this list will stay empty.
Enable alarms in configuration and restart the gateway.
</div>
}
@if (!string.IsNullOrWhiteSpace(_queryError))
{
<div class="alert alert-danger">Alarm query failed: @_queryError</div>
}
<section class="metric-grid compact">
<MetricCard Label="Active (unacked)" Value="@_unackedCount.ToString("N0")" />
<MetricCard Label="Acknowledged" Value="@_ackedCount.ToString("N0")" />
<MetricCard Label="Total Active" Value="@_alarms.Count.ToString("N0")" />
<MetricCard Label="Showing" Value="@FilteredAlarms().Count.ToString("N0")" />
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Filters</h2>
</div>
<div class="alarm-filters">
<label class="alarm-filter-check">
<input type="checkbox" @bind="_showActive" />
<span>Active (unacked)</span>
</label>
<label class="alarm-filter-check">
<input type="checkbox" @bind="_showAcked" />
<span>Acknowledged</span>
</label>
<div class="alarm-filter-field">
<label class="form-label" for="alarm-area">Area</label>
<select id="alarm-area" class="form-select form-select-sm" @bind="_areaFilter">
<option value="">All areas</option>
@foreach (string area in Areas())
{
<option value="@area">@area</option>
}
</select>
</div>
<div class="alarm-filter-field">
<label class="form-label" for="alarm-sev-min">Min severity</label>
<input id="alarm-sev-min" type="number" class="form-control form-control-sm" @bind="_minSeverity" />
</div>
<div class="alarm-filter-field">
<label class="form-label" for="alarm-sev-max">Max severity</label>
<input id="alarm-sev-max" type="number" class="form-control form-control-sm" @bind="_maxSeverity" />
</div>
<div class="alarm-filter-field alarm-filter-grow">
<label class="form-label" for="alarm-search">Search</label>
<input id="alarm-search" class="form-control form-control-sm"
placeholder="Reference, source or description…"
@bind="_search" @bind:event="oninput" />
</div>
</div>
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Active Alarms</h2>
</div>
@{
IReadOnlyList<DashboardActiveAlarm> rows = FilteredAlarms();
}
@if (rows.Count == 0)
{
<div class="empty-state">
@if (_alarms.Count == 0)
{
<span>No alarms are currently Active or ActiveAcked.</span>
}
else
{
<span>No alarms match the current filters.</span>
}
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">State</th>
<th scope="col">Severity</th>
<th scope="col">Alarm Reference</th>
<th scope="col">Source</th>
<th scope="col">Type</th>
<th scope="col">Area</th>
<th scope="col">Last Transition</th>
<th scope="col">Operator</th>
</tr>
</thead>
<tbody>
@foreach (DashboardActiveAlarm alarm in rows)
{
<tr>
<td><span class="alarm-state @StateClass(alarm.State)">@StateText(alarm.State)</span></td>
<td class="alarm-severity">@alarm.Severity</td>
<td>
<code>@alarm.Reference</code>
@if (!string.IsNullOrWhiteSpace(alarm.Description))
{
<div class="alarm-desc">@alarm.Description</div>
}
</td>
<td>@DashboardDisplay.Text(alarm.Source)</td>
<td>@DashboardDisplay.Text(alarm.AlarmType)</td>
<td>@DashboardDisplay.Text(alarm.Area)</td>
<td>@(alarm.LastTransition is { } ts ? DashboardDisplay.DateTime(ts) : "-")</td>
<td>
@DashboardDisplay.Text(alarm.OperatorUser)
@if (!string.IsNullOrWhiteSpace(alarm.OperatorComment))
{
<div class="alarm-desc">@alarm.OperatorComment</div>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="browse-search-note">
Cleared alarms are not retained — this list reflects only alarms currently Active or
ActiveAcked, refreshed every 3 seconds.
</div>
</section>
@code {
private readonly List<DashboardActiveAlarm> _alarms = [];
private string? _queryError;
private int? _workerPid;
private DateTimeOffset? _lastRefresh;
private int _unackedCount;
private int _ackedCount;
private bool _showActive = true;
private bool _showAcked;
private string _areaFilter = string.Empty;
private int _minSeverity;
private int _maxSeverity = 1000;
private string _search = string.Empty;
private readonly CancellationTokenSource _cts = new();
private Task? _pollTask;
/// <inheritdoc />
protected override void OnInitialized()
{
_pollTask = PollLoopAsync();
}
private string HeaderLine()
{
string refreshed = _lastRefresh is { } at
? $"refreshed {DashboardDisplay.DateTime(at)}"
: "awaiting first refresh";
return _workerPid is int pid ? $"{refreshed} · worker pid {pid}" : refreshed;
}
private IReadOnlyList<string> Areas()
{
return _alarms
.Select(alarm => alarm.Area)
.Where(area => !string.IsNullOrWhiteSpace(area))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(area => area, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private IReadOnlyList<DashboardActiveAlarm> FilteredAlarms()
{
string query = _search.Trim();
return _alarms
.Where(MatchesState)
.Where(alarm => _areaFilter.Length == 0
|| string.Equals(alarm.Area, _areaFilter, StringComparison.OrdinalIgnoreCase))
.Where(alarm => alarm.Severity >= _minSeverity && alarm.Severity <= _maxSeverity)
.Where(alarm => query.Length == 0
|| alarm.Reference.Contains(query, StringComparison.OrdinalIgnoreCase)
|| alarm.Source.Contains(query, StringComparison.OrdinalIgnoreCase)
|| alarm.Description.Contains(query, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(alarm => alarm.Severity)
.ThenByDescending(alarm => alarm.LastTransition ?? DateTimeOffset.MinValue)
.ToArray();
}
private bool MatchesState(DashboardActiveAlarm alarm)
{
return alarm.State switch
{
AlarmConditionState.Active => _showActive,
AlarmConditionState.ActiveAcked => _showAcked,
_ => true,
};
}
private static string StateText(AlarmConditionState state)
{
return state switch
{
AlarmConditionState.Active => "Active",
AlarmConditionState.ActiveAcked => "Acked",
AlarmConditionState.Inactive => "Inactive",
_ => "Unknown",
};
}
private static string StateClass(AlarmConditionState state)
{
return state switch
{
AlarmConditionState.Active => "alarm-state-active",
AlarmConditionState.ActiveAcked => "alarm-state-acked",
_ => "alarm-state-other",
};
}
private async Task PollLoopAsync()
{
try
{
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
using PeriodicTimer timer = new(TimeSpan.FromSeconds(3));
while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
{
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
}
}
private async Task RefreshAlarmsAsync()
{
DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
_queryError = result.Error;
_workerPid = result.WorkerProcessId;
_lastRefresh = DateTimeOffset.UtcNow;
_alarms.Clear();
_alarms.AddRange(result.Alarms);
_unackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.Active);
_ackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.ActiveAcked);
StateHasChanged();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_pollTask is not null)
{
try
{
await _pollTask;
}
catch (OperationCanceledException)
{
}
}
_cts.Dispose();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,466 @@
@page "/apikeys"
@page "/dashboard/apikeys"
@inherits DashboardPageBase
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IDashboardApiKeyManagementService ApiKeyManagementService
<PageTitle>Dashboard API Keys</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading API keys.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>API Keys</h1>
<div class="text-secondary">@Snapshot.ApiKeys.Count key rows</div>
</div>
@if (CanManageApiKeys)
{
<button type="button" class="btn btn-primary" @onclick="OpenCreateDialog">
Create API Key
</button>
}
</div>
@if (CanManageApiKeys)
{
@if (!string.IsNullOrWhiteSpace(ResultMessage))
{
<div class="alert @(LastOperationSucceeded ? "alert-success" : "alert-danger")" role="alert">
@ResultMessage
@if (!string.IsNullOrWhiteSpace(LastGeneratedApiKey))
{
<div class="mt-2">
<code class="one-time-secret">@LastGeneratedApiKey</code>
</div>
}
</div>
}
@if (IsCreateDialogOpen)
{
<div class="modal-backdrop fade show"></div>
<div class="modal fade show api-key-create-modal" role="dialog" aria-modal="true" aria-labelledby="createApiKeyTitle">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<EditForm Model="@CreateModel" OnSubmit="@CreateApiKeyAsync">
<div class="modal-header">
<h2 class="modal-title h5" id="createApiKeyTitle">Create API Key</h2>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseCreateDialog"></button>
</div>
<div class="modal-body">
<div class="api-key-management-grid">
<div class="mb-3">
<label for="keyId" class="form-label">Key ID</label>
<input id="keyId" class="form-control" @bind="CreateModel.KeyId" @bind:event="oninput" />
</div>
<div class="mb-3">
<label for="displayName" class="form-label">Display Name</label>
<input id="displayName" class="form-control" @bind="CreateModel.DisplayName" @bind:event="oninput" />
</div>
</div>
<fieldset class="mb-3">
<legend class="form-label">Scopes</legend>
<div class="scope-grid">
@foreach (string scope in AvailableScopes)
{
<label class="form-check">
<input class="form-check-input" type="checkbox"
checked="@IsScopeSelected(scope)"
@onchange="eventArgs => SetScope(scope, eventArgs)" />
<span class="form-check-label">@scope</span>
</label>
}
</div>
</fieldset>
<div class="api-key-management-grid">
<div class="mb-3">
<label for="readSubtrees" class="form-label">Read subtrees</label>
<textarea id="readSubtrees" class="form-control" rows="2" @bind="CreateModel.ReadSubtrees" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="writeSubtrees" class="form-label">Write subtrees</label>
<textarea id="writeSubtrees" class="form-control" rows="2" @bind="CreateModel.WriteSubtrees" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="readTagGlobs" class="form-label">Read tag globs</label>
<textarea id="readTagGlobs" class="form-control" rows="2" @bind="CreateModel.ReadTagGlobs" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="writeTagGlobs" class="form-label">Write tag globs</label>
<textarea id="writeTagGlobs" class="form-control" rows="2" @bind="CreateModel.WriteTagGlobs" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="browseSubtrees" class="form-label">Browse subtrees</label>
<textarea id="browseSubtrees" class="form-control" rows="2" @bind="CreateModel.BrowseSubtrees" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="maxWriteClassification" class="form-label">Max write classification</label>
<input id="maxWriteClassification" class="form-control" @bind="CreateModel.MaxWriteClassification" @bind:event="oninput" />
</div>
</div>
<div class="d-flex flex-wrap gap-3">
<label class="form-check">
<InputCheckbox class="form-check-input" @bind-Value="CreateModel.ReadAlarmOnly" />
<span class="form-check-label">Read alarm only</span>
</label>
<label class="form-check">
<InputCheckbox class="form-check-input" @bind-Value="CreateModel.ReadHistorizedOnly" />
<span class="form-check-label">Read historized only</span>
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" disabled="@IsBusy" @onclick="CloseCreateDialog">
Cancel
</button>
<button type="submit" class="btn btn-primary" disabled="@IsBusy">Create Key</button>
</div>
</EditForm>
</div>
</div>
</div>
}
}
<section class="dashboard-section">
@if (Snapshot.ApiKeys.Count == 0)
{
<div class="empty-state">No API keys are available for display.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle dashboard-table">
<thead>
<tr>
<th scope="col">Key</th>
<th scope="col">Status</th>
<th scope="col">Display Name</th>
<th scope="col">Scopes</th>
<th scope="col">Constraints</th>
<th scope="col">Created</th>
<th scope="col">Last Used</th>
@if (CanManageApiKeys)
{
<th scope="col">Actions</th>
}
</tr>
</thead>
<tbody>
@foreach (DashboardApiKeySummary key in Snapshot.ApiKeys)
{
<tr>
<td><code>@key.KeyId</code></td>
<td><StatusBadge Text="@(key.RevokedUtc is null ? "Active" : "Revoked")" /></td>
<td>@DashboardDisplay.Text(key.DisplayName)</td>
<td>@DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal)))</td>
<td>@DashboardDisplay.Text(ConstraintText(key.Constraints))</td>
<td>@DashboardDisplay.DateTime(key.CreatedUtc)</td>
<td>@DashboardDisplay.DateTime(key.LastUsedUtc)</td>
@if (CanManageApiKeys)
{
<td>
<div class="btn-group btn-group-sm" role="group" aria-label="API key actions">
@if (key.RevokedUtc is null)
{
@* Rotate clears revoked_utc, which would silently reactivate a
deliberately revoked key. Only offer it for active keys so a
revoked key is not un-revoked as a side effect of rotation. *@
<button type="button" class="btn btn-outline-secondary"
disabled="@IsBusy"
@onclick="() => RotateApiKeyAsync(key.KeyId)">
Rotate
</button>
<button type="button" class="btn btn-outline-danger"
disabled="@IsBusy"
@onclick="() => RevokeApiKeyAsync(key.KeyId)">
Revoke
</button>
}
else
{
<span class="text-muted small">No actions</span>
}
</div>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private static readonly string[] AvailableScopes =
[
GatewayScopes.SessionOpen,
GatewayScopes.SessionClose,
GatewayScopes.InvokeRead,
GatewayScopes.InvokeWrite,
GatewayScopes.InvokeSecure,
GatewayScopes.EventsRead,
GatewayScopes.MetadataRead,
GatewayScopes.Admin
];
private ApiKeyCreateModel CreateModel { get; } = new();
private bool CanManageApiKeys { get; set; }
private bool IsBusy { get; set; }
private bool IsCreateDialogOpen { get; set; }
private string? ResultMessage { get; set; }
private bool LastOperationSucceeded { get; set; }
private string? LastGeneratedApiKey { get; set; }
protected override async Task OnInitializedAsync()
{
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
.ConfigureAwait(false);
CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User);
}
private async Task CreateApiKeyAsync()
{
if (IsBusy)
{
return;
}
if (!TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? validationMessage))
{
SetResult(DashboardApiKeyManagementResult.Fail(validationMessage ?? "API key request is invalid."));
return;
}
await RunManagementActionAsync(user => ApiKeyManagementService.CreateAsync(
user,
request,
CancellationToken.None))
.ConfigureAwait(false);
}
private async Task RevokeApiKeyAsync(string keyId)
{
await RunManagementActionAsync(user => ApiKeyManagementService.RevokeAsync(
user,
keyId,
CancellationToken.None))
.ConfigureAwait(false);
}
private async Task RotateApiKeyAsync(string keyId)
{
await RunManagementActionAsync(user => ApiKeyManagementService.RotateAsync(
user,
keyId,
CancellationToken.None))
.ConfigureAwait(false);
}
private async Task RunManagementActionAsync(
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> action)
{
if (IsBusy)
{
return;
}
IsBusy = true;
try
{
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
.ConfigureAwait(false);
CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User);
DashboardApiKeyManagementResult result = await action(authenticationState.User).ConfigureAwait(false);
SetResult(result);
if (result.Succeeded && result.ApiKey is not null)
{
CreateModel.Reset();
IsCreateDialogOpen = false;
}
}
finally
{
IsBusy = false;
}
}
private void SetResult(DashboardApiKeyManagementResult result)
{
LastOperationSucceeded = result.Succeeded;
ResultMessage = result.Message;
LastGeneratedApiKey = result.ApiKey;
}
private void OpenCreateDialog()
{
IsCreateDialogOpen = true;
}
private void CloseCreateDialog()
{
if (!IsBusy)
{
IsCreateDialogOpen = false;
}
}
private bool TryBuildCreateRequest(
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out DashboardApiKeyManagementRequest? request,
out string? validationMessage)
{
request = null;
validationMessage = null;
if (!string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification)
&& !int.TryParse(
CreateModel.MaxWriteClassification,
System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture,
out int _))
{
validationMessage = "Max write classification must be an integer.";
return false;
}
int? maxWriteClassification = string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification)
? null
: int.Parse(
CreateModel.MaxWriteClassification,
System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture);
request = new DashboardApiKeyManagementRequest(
KeyId: CreateModel.KeyId,
DisplayName: CreateModel.DisplayName,
Scopes: CreateModel.SelectedScopes,
Constraints: new ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyConstraints(
ReadSubtrees: ParseList(CreateModel.ReadSubtrees),
WriteSubtrees: ParseList(CreateModel.WriteSubtrees),
ReadTagGlobs: ParseList(CreateModel.ReadTagGlobs),
WriteTagGlobs: ParseList(CreateModel.WriteTagGlobs),
MaxWriteClassification: maxWriteClassification,
BrowseSubtrees: ParseList(CreateModel.BrowseSubtrees),
ReadAlarmOnly: CreateModel.ReadAlarmOnly,
ReadHistorizedOnly: CreateModel.ReadHistorizedOnly));
return true;
}
private bool IsScopeSelected(string scope)
{
return CreateModel.SelectedScopes.Contains(scope);
}
private void SetScope(string scope, ChangeEventArgs eventArgs)
{
bool selected = eventArgs.Value is bool value && value;
if (selected)
{
CreateModel.SelectedScopes.Add(scope);
}
else
{
CreateModel.SelectedScopes.Remove(scope);
}
}
private static string ConstraintText(ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyConstraints constraints)
{
if (constraints.IsEmpty)
{
return "unconstrained";
}
List<string> parts = [];
AddList(parts, "read_subtrees", constraints.ReadSubtrees);
AddList(parts, "write_subtrees", constraints.WriteSubtrees);
AddList(parts, "read_tag_globs", constraints.ReadTagGlobs);
AddList(parts, "write_tag_globs", constraints.WriteTagGlobs);
AddList(parts, "browse_subtrees", constraints.BrowseSubtrees);
if (constraints.MaxWriteClassification is { } max)
{
parts.Add($"max_write_classification={max}");
}
if (constraints.ReadAlarmOnly)
{
parts.Add("read_alarm_only");
}
if (constraints.ReadHistorizedOnly)
{
parts.Add("read_historized_only");
}
return string.Join("; ", parts);
}
private static void AddList(List<string> parts, string name, IReadOnlyList<string> values)
{
if (values.Count > 0)
{
parts.Add($"{name}=[{string.Join(", ", values)}]");
}
}
private static IReadOnlyList<string> ParseList(string? value)
{
return (value ?? string.Empty)
.Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToArray();
}
private sealed class ApiKeyCreateModel
{
public string KeyId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public HashSet<string> SelectedScopes { get; } = new(StringComparer.Ordinal);
public string ReadSubtrees { get; set; } = string.Empty;
public string WriteSubtrees { get; set; } = string.Empty;
public string ReadTagGlobs { get; set; } = string.Empty;
public string WriteTagGlobs { get; set; } = string.Empty;
public string BrowseSubtrees { get; set; } = string.Empty;
public string MaxWriteClassification { get; set; } = string.Empty;
public bool ReadAlarmOnly { get; set; }
public bool ReadHistorizedOnly { get; set; }
public void Reset()
{
KeyId = string.Empty;
DisplayName = string.Empty;
SelectedScopes.Clear();
ReadSubtrees = string.Empty;
WriteSubtrees = string.Empty;
ReadTagGlobs = string.Empty;
WriteTagGlobs = string.Empty;
BrowseSubtrees = string.Empty;
MaxWriteClassification = string.Empty;
ReadAlarmOnly = false;
ReadHistorizedOnly = false;
}
}
}
@@ -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);
}
}
@@ -0,0 +1,78 @@
@page "/"
@page "/dashboard/"
@inherits DashboardPageBase
<PageTitle>MXAccess Gateway Dashboard</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading dashboard snapshot.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Overview</h1>
<div class="text-secondary">Generated @DashboardDisplay.DateTime(Snapshot.GeneratedAt)</div>
</div>
<StatusBadge Text="@Snapshot.GatewayStatus" />
</div>
<section class="metric-grid">
<MetricCard Label="Uptime" Value="@DashboardDisplay.Duration(Snapshot.GatewayUptime)" Detail="@Snapshot.GatewayVersion" />
<MetricCard Label="Open Sessions" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.sessions.open"))" />
<MetricCard Label="Workers Running" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.workers.running"))" />
<MetricCard Label="Event Queue Depth" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.worker_queue.depth"))" />
<MetricCard Label="Commands Failed" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.commands.failed"))" />
<MetricCard Label="Events Received" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.received"))" />
<MetricCard Label="Faults" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.faults"))" />
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows"))" />
</section>
<section class="dashboard-section">
<div class="section-heading d-flex align-items-center gap-2">
<h2>Galaxy Repository</h2>
<StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" />
<NavLink class="ms-auto small" href="galaxy">View browse details &rarr;</NavLink>
</div>
<div class="metric-grid compact">
<MetricCard Label="Last Deploy" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)" />
<MetricCard Label="Objects" Value="@DashboardDisplay.Count(Snapshot.Galaxy.ObjectCount)" Detail="@($"{Snapshot.Galaxy.AreaCount:N0} areas")" />
<MetricCard Label="Attributes" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AttributeCount)" />
<MetricCard Label="Historized" Value="@DashboardDisplay.Count(Snapshot.Galaxy.HistorizedAttributeCount)" />
<MetricCard Label="Alarms" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AlarmAttributeCount)" />
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@GalaxyRefreshDetail()" />
</div>
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
{
<div class="empty-state mt-2">@Snapshot.Galaxy.LastError</div>
}
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Recent Faults</h2>
</div>
<FaultList Faults="@Snapshot.Faults" />
</section>
}
@code {
private string? GalaxyRefreshDetail()
{
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
if (galaxy.LastQueriedAt is null)
{
return "never queried";
}
if (galaxy.LastSuccessAt is null)
{
return "no successful refresh yet";
}
return galaxy.LastQueriedAt > galaxy.LastSuccessAt
? $"last attempt {DashboardDisplay.DateTime(galaxy.LastQueriedAt)}"
: null;
}
}
@@ -0,0 +1,66 @@
@page "/events"
@page "/dashboard/events"
@inherits DashboardPageBase
<PageTitle>Dashboard Events</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading event diagnostics.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Events</h1>
<div class="text-secondary">Generated @DashboardDisplay.DateTime(Snapshot.GeneratedAt)</div>
</div>
</div>
<section class="metric-grid compact">
<MetricCard Label="Events Received" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.received"))" />
<MetricCard Label="Worker Event Queue Depth" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.worker_queue.depth"))" />
<MetricCard Label="Stream Queue Depth" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.grpc_stream_queue.depth"))" />
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows"))" />
<MetricCard Label="Stream Disconnects" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.grpc.streams.disconnected"))" />
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Event Families</h2>
</div>
@if (EventFamilyMetrics.Count == 0)
{
<div class="empty-state">No event family counters recorded.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">Family</th>
<th scope="col">Count</th>
</tr>
</thead>
<tbody>
@foreach (DashboardMetricSummary metric in EventFamilyMetrics)
{
<tr>
<td>@metric.Dimension</td>
<td>@DashboardDisplay.Count(metric.Value)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private IReadOnlyList<DashboardMetricSummary> EventFamilyMetrics => Snapshot?.Metrics
.Where(metric => metric.Name == "mxgateway.events.received" && metric.Dimension is not null)
.OrderBy(metric => metric.Dimension, StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
}
@@ -0,0 +1,197 @@
@page "/galaxy"
@page "/dashboard/galaxy"
@inherits DashboardPageBase
<PageTitle>Dashboard Galaxy</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading Galaxy summary.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Galaxy Repository</h1>
<div class="text-secondary">@RefreshHeading()</div>
</div>
<StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" />
</div>
<section class="metric-grid">
<MetricCard Label="Last Deploy" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)" Detail="@DeployAge()" Wide="true" />
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@LastAttemptDetail()" Wide="true" />
<MetricCard Label="Objects" Value="@DashboardDisplay.Count(Snapshot.Galaxy.ObjectCount)" Detail="@($"{Snapshot.Galaxy.AreaCount:N0} areas")" />
<MetricCard Label="Attributes" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AttributeCount)" Detail="dynamic, deployed" />
<MetricCard Label="Historized" Value="@DashboardDisplay.Count(Snapshot.Galaxy.HistorizedAttributeCount)" />
<MetricCard Label="Alarms" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AlarmAttributeCount)" />
</section>
@if (Snapshot.Galaxy.Status == DashboardGalaxyStatus.Unknown)
{
<section class="dashboard-section">
<div class="empty-state">
Galaxy summary has not been collected yet. The dashboard refreshes the
summary every @RefreshIntervalSeconds() seconds via the
<code>GalaxyRepository</code> service.
</div>
</section>
}
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
{
<section class="dashboard-section">
<div class="section-heading">
<h2>Last Error</h2>
</div>
<div class="empty-state">@Snapshot.Galaxy.LastError</div>
</section>
}
<section class="dashboard-section">
<div class="section-heading">
<h2>Object Categories</h2>
</div>
@if (Snapshot.Galaxy.ObjectCategories.Count == 0)
{
<div class="empty-state">No deployed objects observed.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">Category</th>
<th scope="col">Category ID</th>
<th scope="col">Objects</th>
</tr>
</thead>
<tbody>
@foreach (DashboardGalaxyCategoryCount row in Snapshot.Galaxy.ObjectCategories)
{
<tr>
<td>@row.CategoryName</td>
<td><code>@row.CategoryId</code></td>
<td>@DashboardDisplay.Count(row.ObjectCount)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Top Templates</h2>
</div>
@if (Snapshot.Galaxy.TopTemplates.Count == 0)
{
<div class="empty-state">No template usage observed.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">Template</th>
<th scope="col">Instances</th>
</tr>
</thead>
<tbody>
@foreach (DashboardGalaxyTemplateUsage row in Snapshot.Galaxy.TopTemplates)
{
<tr>
<td><code>@row.TemplateName</code></td>
<td>@DashboardDisplay.Count(row.InstanceCount)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Sync Info</h2>
</div>
<div class="table-responsive">
<table class="table table-sm dashboard-table details-table">
<tbody>
<tr><th scope="row">Status</th><td><StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" /></td></tr>
<tr><th scope="row">Last successful refresh</th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)</td></tr>
<tr><th scope="row">Last attempt</th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastQueriedAt)</td></tr>
<tr><th scope="row">Galaxy <code>time_of_last_deploy</code></th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)</td></tr>
<tr><th scope="row">Refresh interval</th><td>@RefreshIntervalSeconds() seconds</td></tr>
<tr><th scope="row">Connection string</th><td><code>@DashboardDisplay.Text(GalaxyConnectionStringDisplay())</code></td></tr>
<tr><th scope="row">Command timeout</th><td>@CommandTimeoutSeconds() seconds</td></tr>
</tbody>
</table>
</div>
<div class="text-secondary small mt-2">
Browse data is served by the <code>galaxy_repository.v1.GalaxyRepository</code> gRPC
service. Clients call <code>DiscoverHierarchy</code> for the full tree and
<code>GetLastDeployTime</code> to detect redeployments.
</div>
</section>
}
@code {
[Inject]
private IOptions<ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepositoryOptions> GalaxyOptions { get; set; } = null!;
private string RefreshHeading()
{
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
return galaxy.LastSuccessAt is null
? "Awaiting first successful refresh"
: $"Refreshed {DashboardDisplay.DateTime(galaxy.LastSuccessAt)}";
}
private string? DeployAge()
{
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
if (galaxy.LastDeployTime is null || galaxy.LastSuccessAt is null)
{
return null;
}
TimeSpan age = galaxy.LastSuccessAt.Value - galaxy.LastDeployTime.Value;
if (age < TimeSpan.Zero)
{
return null;
}
return $"{DashboardDisplay.Duration(age)} ago";
}
private string? LastAttemptDetail()
{
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
if (galaxy.LastQueriedAt is null)
{
return "never queried";
}
if (galaxy.LastSuccessAt is null)
{
return "no successful refresh yet";
}
return galaxy.LastQueriedAt > galaxy.LastSuccessAt
? $"last attempt {DashboardDisplay.DateTime(galaxy.LastQueriedAt)}"
: null;
}
private int RefreshIntervalSeconds() => Math.Max(1, GalaxyOptions.Value.DashboardRefreshIntervalSeconds);
private int CommandTimeoutSeconds() => GalaxyOptions.Value.CommandTimeoutSeconds;
private string GalaxyConnectionStringDisplay()
{
return DashboardConnectionStringDisplay.GalaxyRepositoryConnectionString(GalaxyOptions.Value.ConnectionString);
}
}
@@ -0,0 +1,71 @@
@page "/sessions/{SessionId}"
@page "/dashboard/sessions/{SessionId}"
@inherits DashboardPageBase
<PageTitle>Dashboard Session</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading session.</div>
}
else if (CurrentSession is null)
{
<section class="dashboard-section">
<h1 class="h4 mb-3">Session Not Found</h1>
<p class="mb-0">The session is not present in the current snapshot.</p>
</section>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Session Details</h1>
<div class="text-secondary"><code>@CurrentSession.SessionId</code></div>
</div>
<StatusBadge Text="@CurrentSession.State.ToString()" />
</div>
<section class="dashboard-section">
<div class="section-heading">
<h2>Session</h2>
</div>
<div class="table-responsive">
<table class="table table-sm dashboard-table details-table">
<tbody>
<tr><th scope="row">Backend</th><td>@CurrentSession.BackendName</td></tr>
<tr><th scope="row">Client identity</th><td>@DashboardDisplay.Text(CurrentSession.ClientIdentity)</td></tr>
<tr><th scope="row">Client session</th><td>@DashboardDisplay.Text(CurrentSession.ClientSessionName)</td></tr>
<tr><th scope="row">Client correlation</th><td>@DashboardDisplay.Text(CurrentSession.ClientCorrelationId)</td></tr>
<tr><th scope="row">Opened</th><td>@DashboardDisplay.DateTime(CurrentSession.OpenedAt)</td></tr>
<tr><th scope="row">Last activity</th><td>@DashboardDisplay.DateTime(CurrentSession.LastClientActivityAt)</td></tr>
<tr><th scope="row">Lease expires</th><td>@DashboardDisplay.DateTime(CurrentSession.LeaseExpiresAt)</td></tr>
<tr><th scope="row">Events received</th><td>@DashboardDisplay.Count(CurrentSession.EventsReceived)</td></tr>
<tr><th scope="row">Last fault</th><td>@DashboardDisplay.Text(CurrentSession.LastFault)</td></tr>
</tbody>
</table>
</div>
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Worker</h2>
</div>
<div class="table-responsive">
<table class="table table-sm dashboard-table details-table">
<tbody>
<tr><th scope="row">Process id</th><td>@(CurrentSession.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td></tr>
<tr><th scope="row">State</th><td><StatusBadge Text="@(CurrentSession.WorkerState?.ToString() ?? "-")" /></td></tr>
<tr><th scope="row">Last heartbeat</th><td>@DashboardDisplay.DateTime(CurrentSession.LastWorkerHeartbeatAt)</td></tr>
</tbody>
</table>
</div>
</section>
}
@code {
[Parameter]
public string SessionId { get; set; } = string.Empty;
private DashboardSessionSummary? CurrentSession => Snapshot?.Sessions.FirstOrDefault(session =>
string.Equals(session.SessionId, SessionId, StringComparison.Ordinal));
}
@@ -0,0 +1,70 @@
@page "/sessions"
@page "/dashboard/sessions"
@inherits DashboardPageBase
<PageTitle>Dashboard Sessions</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading sessions.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Sessions</h1>
<div class="text-secondary">@Snapshot.Sessions.Count session rows</div>
</div>
</div>
<section class="dashboard-section">
@if (Snapshot.Sessions.Count == 0)
{
<div class="empty-state">No sessions are active or retained.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle dashboard-table">
<thead>
<tr>
<th scope="col">Session</th>
<th scope="col">State</th>
<th scope="col">Client</th>
<th scope="col">Backend</th>
<th scope="col">Worker</th>
<th scope="col">Events</th>
<th scope="col">Opened</th>
<th scope="col">Activity</th>
<th scope="col">Heartbeat</th>
<th scope="col">Fault</th>
</tr>
</thead>
<tbody>
@foreach (DashboardSessionSummary session in Snapshot.Sessions)
{
<tr>
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(session.SessionId)}")"><code>@session.SessionId</code></NavLink></td>
<td><StatusBadge Text="@session.State.ToString()" /></td>
<td>@DashboardDisplay.Text(session.ClientIdentity)</td>
<td>@session.BackendName</td>
<td>
@(session.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")
@if (session.WorkerState is not null)
{
<span class="ms-1"><StatusBadge Text="@session.WorkerState.ToString()" /></span>
}
</td>
<td>@DashboardDisplay.Count(session.EventsReceived)</td>
<td>@DashboardDisplay.DateTime(session.OpenedAt)</td>
<td>@DashboardDisplay.DateTime(session.LastClientActivityAt)</td>
<td>@DashboardDisplay.DateTime(session.LastWorkerHeartbeatAt)</td>
<td>@DashboardDisplay.Text(session.LastFault)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@@ -0,0 +1,57 @@
@page "/settings"
@page "/dashboard/settings"
@inherits DashboardPageBase
<PageTitle>Dashboard Settings</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading settings.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Settings</h1>
<div class="text-secondary">Effective gateway configuration</div>
</div>
</div>
<section class="dashboard-section">
<div class="table-responsive">
<table class="table table-sm dashboard-table details-table">
<tbody>
<tr><th scope="row">Authentication mode</th><td>@Snapshot.Configuration.Authentication.Mode</td></tr>
<tr><th scope="row">Auth database</th><td><code>@Snapshot.Configuration.Authentication.SqlitePath</code></td></tr>
<tr><th scope="row">Pepper secret</th><td>@Snapshot.Configuration.Authentication.PepperSecretName</td></tr>
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
<tr><th scope="row">LDAP TLS</th><td>@Snapshot.Configuration.Ldap.UseTls</td></tr>
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
<tr><th scope="row">LDAP username attribute</th><td>@Snapshot.Configuration.Ldap.UserNameAttribute</td></tr>
<tr><th scope="row">LDAP group attribute</th><td>@Snapshot.Configuration.Ldap.GroupAttribute</td></tr>
<tr><th scope="row">LDAP required group</th><td>@Snapshot.Configuration.Ldap.RequiredGroup</td></tr>
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
<tr><th scope="row">Shutdown timeout</th><td>@Snapshot.Configuration.Worker.ShutdownTimeoutSeconds seconds</td></tr>
<tr><th scope="row">Heartbeat grace</th><td>@Snapshot.Configuration.Worker.HeartbeatGraceSeconds seconds</td></tr>
<tr><th scope="row">Default command timeout</th><td>@Snapshot.Configuration.Sessions.DefaultCommandTimeoutSeconds seconds</td></tr>
<tr><th scope="row">Max sessions</th><td>@Snapshot.Configuration.Sessions.MaxSessions</td></tr>
<tr><th scope="row">Event queue capacity</th><td>@Snapshot.Configuration.Events.QueueCapacity</td></tr>
<tr><th scope="row">Backpressure policy</th><td>@Snapshot.Configuration.Events.BackpressurePolicy</td></tr>
<tr><th scope="row">Dashboard enabled</th><td>@Snapshot.Configuration.Dashboard.Enabled</td></tr>
<tr><th scope="row">Dashboard path</th><td>@Snapshot.Configuration.Dashboard.PathBase</td></tr>
<tr><th scope="row">Require admin scope</th><td>@Snapshot.Configuration.Dashboard.RequireAdminScope</td></tr>
<tr><th scope="row">Anonymous localhost</th><td>@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost</td></tr>
<tr><th scope="row">Snapshot interval</th><td>@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms</td></tr>
<tr><th scope="row">Show tag values</th><td>@Snapshot.Configuration.Dashboard.ShowTagValues</td></tr>
<tr><th scope="row">Worker protocol</th><td>@Snapshot.Configuration.Protocol.WorkerProtocolVersion</td></tr>
</tbody>
</table>
</div>
</section>
}
@@ -0,0 +1,54 @@
@page "/workers"
@page "/dashboard/workers"
@inherits DashboardPageBase
<PageTitle>Dashboard Workers</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading workers.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Workers</h1>
<div class="text-secondary">@Snapshot.Workers.Count worker rows</div>
</div>
</div>
<section class="dashboard-section">
@if (Snapshot.Workers.Count == 0)
{
<div class="empty-state">No worker processes are attached.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle dashboard-table">
<thead>
<tr>
<th scope="col">Process</th>
<th scope="col">State</th>
<th scope="col">Session</th>
<th scope="col">Heartbeat</th>
<th scope="col">Fault</th>
</tr>
</thead>
<tbody>
@foreach (DashboardWorkerSummary worker in Snapshot.Workers)
{
<tr>
<td>@(worker.ProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
<td><StatusBadge Text="@worker.State.ToString()" /></td>
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(worker.SessionId)}")"><code>@worker.SessionId</code></NavLink></td>
<td>@DashboardDisplay.DateTime(worker.LastHeartbeatAt)</td>
<td>@DashboardDisplay.Text(worker.LastFault)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@@ -0,0 +1,15 @@
<Router AppAssembly="@typeof(Routes).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(DashboardLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(DashboardLayout)">
<PageTitle>Dashboard - Not Found</PageTitle>
<section class="dashboard-section">
<h1 class="h4 mb-3">Not Found</h1>
<p class="mb-0">The requested dashboard page does not exist.</p>
</section>
</LayoutView>
</NotFound>
</Router>
@@ -0,0 +1,102 @@
@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy
@*
Recursive Browse hierarchy node. Renders one Galaxy object, its child
objects (recursively), and its attributes as right-clickable tag rows.
Expansion state is local; children render only while expanded.
*@
<div class="tree-node">
<div class="tree-row @(Node.IsArea ? "tree-row-area" : "tree-row-object")">
@if (Node.HasChildren)
{
<button type="button" class="tree-toggle" @onclick="Toggle" aria-label="Toggle">
@(_expanded ? "▾" : "▸")
</button>
}
else
{
<span class="tree-toggle tree-toggle-empty"></span>
}
<span class="tree-label" @onclick="Toggle">
<span class="tree-icon">@(Node.IsArea ? "▣" : "◇")</span>
<span class="tree-name">@Node.DisplayName</span>
@if (!string.IsNullOrWhiteSpace(Node.Object.TagName)
&& !string.Equals(Node.Object.TagName, Node.DisplayName, StringComparison.Ordinal))
{
<code class="tree-tag">@Node.Object.TagName</code>
}
</span>
</div>
@if (_expanded)
{
<div class="tree-children">
@foreach (DashboardBrowseNode child in Node.Children)
{
<BrowseTreeNodeView Node="child" OnAddTag="OnAddTag" OnTagContextMenu="OnTagContextMenu" />
}
@foreach (GalaxyAttribute attr in Node.Attributes)
{
GalaxyAttribute row = attr;
<div class="tree-attr"
title="@row.FullTagReference"
@oncontextmenu:preventDefault="true"
@oncontextmenu="@(args => OnTagContextMenu.InvokeAsync((args, row)))"
@ondblclick="@(() => OnAddTag.InvokeAsync(row.FullTagReference))">
<span class="tree-toggle tree-toggle-empty"></span>
<span class="attr-icon">·</span>
<span class="attr-name">@row.AttributeName</span>
<span class="attr-type">@DisplayType(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>
}
</div>
@code {
/// <summary>The hierarchy node this view renders.</summary>
[Parameter]
[EditorRequired]
public DashboardBrowseNode Node { get; set; } = null!;
/// <summary>Raised with a tag's full reference when the operator double-clicks it.</summary>
[Parameter]
public EventCallback<string> OnAddTag { get; set; }
/// <summary>Raised when an attribute row is right-clicked, for the context menu.</summary>
[Parameter]
public EventCallback<(MouseEventArgs Event, GalaxyAttribute Attribute)> OnTagContextMenu { get; set; }
private bool _expanded;
private void Toggle()
{
if (Node.HasChildren)
{
_expanded = !_expanded;
}
}
private static string DisplayType(GalaxyAttribute attribute)
{
string baseType = string.IsNullOrWhiteSpace(attribute.DataTypeName)
? "type?"
: attribute.DataTypeName;
if (attribute.IsArray)
{
return attribute.ArrayDimensionPresent
? $"{baseType}[{attribute.ArrayDimension}]"
: $"{baseType}[]";
}
return baseType;
}
}
@@ -0,0 +1,39 @@
@if (Faults.Count == 0)
{
<div class="empty-state">No faults recorded.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle dashboard-table">
<thead>
<tr>
<th scope="col">Observed</th>
<th scope="col">Source</th>
<th scope="col">Session</th>
<th scope="col">Worker</th>
<th scope="col">State</th>
<th scope="col">Message</th>
</tr>
</thead>
<tbody>
@foreach (DashboardFaultSummary fault in Faults)
{
<tr>
<td>@DashboardDisplay.DateTime(fault.ObservedAt)</td>
<td>@fault.Source</td>
<td><code>@DashboardDisplay.Text(fault.SessionId)</code></td>
<td>@(fault.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
<td><StatusBadge Text="@fault.State" /></td>
<td>@fault.Message</td>
</tr>
}
</tbody>
</table>
</div>
}
@code {
[Parameter]
public IReadOnlyList<DashboardFaultSummary> Faults { get; set; } = [];
}
@@ -0,0 +1,25 @@
<div class="card metric-card h-100@(Wide ? " metric-card-wide" : string.Empty)">
<div class="card-body">
<div class="metric-label">@Label</div>
<div class="metric-value">@Value</div>
@if (!string.IsNullOrWhiteSpace(Detail))
{
<div class="metric-detail">@Detail</div>
}
</div>
</div>
@code {
[Parameter]
public string Label { get; set; } = string.Empty;
[Parameter]
public string Value { get; set; } = string.Empty;
[Parameter]
public string? Detail { get; set; }
/// <summary>Spans the card across two grid columns for long values such as timestamps.</summary>
[Parameter]
public bool Wide { get; set; }
}
@@ -0,0 +1,16 @@
<span class="chip @CssClass">@Text</span>
@code {
[Parameter]
public string? Text { get; set; }
private string CssClass => Text switch
{
"Ready" or "Healthy" or "Active" => "chip-ok",
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "chip-warn",
"Stale" or "Degraded" => "chip-warn",
"Faulted" or "Unavailable" => "chip-bad",
"Closed" or "Revoked" or "Unknown" => "chip-idle",
_ => "chip-idle"
};
}
@@ -0,0 +1,13 @@
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Options
@using ZB.MOM.WW.MxGateway.Contracts.Proto
@using ZB.MOM.WW.MxGateway.Server.Configuration
@using ZB.MOM.WW.MxGateway.Server.Dashboard
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Layout
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Shared
@using ZB.MOM.WW.MxGateway.Server.Security.Authorization
@using ZB.MOM.WW.MxGateway.Server.Workers
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@@ -0,0 +1,63 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// One active-alarm row as shown on the dashboard Alarms tab. Projected
/// from an <see cref="ActiveAlarmSnapshot"/> so the Razor component never
/// touches protobuf types directly.
/// </summary>
public sealed record DashboardActiveAlarm(
string Reference,
string Provider,
string Area,
string Source,
string AlarmType,
int Severity,
AlarmConditionState State,
DateTimeOffset? LastTransition,
string OperatorUser,
string OperatorComment,
string Description)
{
/// <summary>Projects a worker active-alarm snapshot into a dashboard alarm row.</summary>
/// <param name="snapshot">The snapshot returned by <c>QueryActiveAlarms</c>.</param>
/// <returns>The projected dashboard alarm.</returns>
public static DashboardActiveAlarm FromSnapshot(ActiveAlarmSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
string provider = string.Empty;
string reference = snapshot.AlarmFullReference ?? string.Empty;
int bang = reference.IndexOf('!', StringComparison.Ordinal);
if (bang > 0)
{
provider = reference[..bang];
}
return new DashboardActiveAlarm(
Reference: reference,
Provider: provider,
Area: snapshot.Category ?? string.Empty,
Source: snapshot.SourceObjectReference ?? string.Empty,
AlarmType: snapshot.AlarmTypeName ?? string.Empty,
Severity: snapshot.Severity,
State: snapshot.CurrentState,
LastTransition: snapshot.LastTransitionTimestamp?.ToDateTimeOffset(),
OperatorUser: snapshot.OperatorUser ?? string.Empty,
OperatorComment: snapshot.OperatorComment ?? string.Empty,
Description: snapshot.Description ?? string.Empty);
}
/// <summary>True when this alarm is active and not yet acknowledged.</summary>
public bool IsUnacknowledged => State == AlarmConditionState.Active;
}
/// <summary>Result of a dashboard active-alarm query.</summary>
/// <param name="Alarms">The active alarms, or an empty list on error.</param>
/// <param name="Error">A diagnostic message when the query failed; otherwise null.</param>
/// <param name="WorkerProcessId">The worker process id backing the dashboard session, when available.</param>
public sealed record DashboardAlarmQueryResult(
IReadOnlyList<DashboardActiveAlarm> Alarms,
string? Error,
int? WorkerProcessId);
@@ -0,0 +1,22 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardApiKeyAuthorization(IOptions<GatewayOptions> options)
{
public bool CanManage(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
{
return false;
}
string requiredGroup = options.Value.Ldap.RequiredGroup;
IEnumerable<string> groups = user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
.Select(claim => claim.Value);
return DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
}
}
@@ -0,0 +1,9 @@
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed record DashboardApiKeyManagementRequest(
string KeyId,
string DisplayName,
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints);
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed record DashboardApiKeyManagementResult(
bool Succeeded,
string Message,
string? ApiKey)
{
public static DashboardApiKeyManagementResult Success(string message, string? apiKey = null)
{
return new DashboardApiKeyManagementResult(true, message, apiKey);
}
public static DashboardApiKeyManagementResult Fail(string message)
{
return new DashboardApiKeyManagementResult(false, message, null);
}
}
@@ -0,0 +1,205 @@
using System.Security.Claims;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardApiKeyManagementService(
DashboardApiKeyAuthorization authorization,
IApiKeyAdminStore adminStore,
IApiKeyAuditStore auditStore,
IApiKeySecretHasher hasher,
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
{
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
public bool CanManage(ClaimsPrincipal user)
{
return authorization.CanManage(user);
}
public async Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
CancellationToken cancellationToken)
{
if (!CanManage(user))
{
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
}
string? validation = ValidateCreateRequest(request);
if (validation is not null)
{
return DashboardApiKeyManagementResult.Fail(validation);
}
string keyId = request.KeyId.Trim();
string secret = ApiKeySecretGenerator.Generate();
string apiKey = FormatApiKey(keyId, secret);
try
{
await adminStore.CreateAsync(
new ApiKeyCreateRequest(
KeyId: keyId,
KeyPrefix: $"mxgw_{keyId}",
SecretHash: hasher.HashSecret(secret),
DisplayName: request.DisplayName.Trim(),
Scopes: request.Scopes,
Constraints: request.Constraints,
CreatedUtc: DateTimeOffset.UtcNow),
cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
return DashboardApiKeyManagementResult.Success("API key created. Copy the key now; it will not be shown again.", apiKey);
}
catch (ApiKeyPepperUnavailableException)
{
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
}
catch (SqliteException exception) when (exception.SqliteErrorCode == 19)
{
return DashboardApiKeyManagementResult.Fail("An API key with that id already exists.");
}
}
public async Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken)
{
if (!CanManage(user))
{
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
}
string? validation = ValidateKeyId(keyId);
if (validation is not null)
{
return DashboardApiKeyManagementResult.Fail(validation);
}
string normalizedKeyId = keyId.Trim();
bool revoked = await adminStore
.RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(
normalizedKeyId,
"dashboard-revoke-key",
revoked ? "revoked" : "not-found-or-already-revoked",
cancellationToken)
.ConfigureAwait(false);
return revoked
? DashboardApiKeyManagementResult.Success("API key revoked.")
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
}
public async Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken)
{
if (!CanManage(user))
{
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
}
string? validation = ValidateKeyId(keyId);
if (validation is not null)
{
return DashboardApiKeyManagementResult.Fail(validation);
}
string normalizedKeyId = keyId.Trim();
string secret = ApiKeySecretGenerator.Generate();
string apiKey = FormatApiKey(normalizedKeyId, secret);
try
{
bool rotated = await adminStore
.RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(
normalizedKeyId,
"dashboard-rotate-key",
rotated ? "rotated" : "not-found",
cancellationToken)
.ConfigureAwait(false);
return rotated
? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey)
: DashboardApiKeyManagementResult.Fail("API key was not found.");
}
catch (ApiKeyPepperUnavailableException)
{
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
}
}
private async Task AppendAuditAsync(
string? keyId,
string eventType,
string? details,
CancellationToken cancellationToken)
{
await auditStore.AppendAsync(
new ApiKeyAuditEntry(
KeyId: keyId,
EventType: eventType,
RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(),
Details: details),
cancellationToken)
.ConfigureAwait(false);
}
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
{
string? keyIdValidation = ValidateKeyId(request.KeyId);
if (keyIdValidation is not null)
{
return keyIdValidation;
}
if (string.IsNullOrWhiteSpace(request.DisplayName))
{
return "Display name is required.";
}
string[] unknownScopes = request.Scopes
.Where(scope => !GatewayScopes.IsKnown(scope))
.ToArray();
if (unknownScopes.Length > 0)
{
return $"Unknown scope(s): {string.Join(", ", unknownScopes)}. "
+ $"Valid scopes are: {string.Join(", ", GatewayScopes.All)}.";
}
return null;
}
private static string? ValidateKeyId(string keyId)
{
if (string.IsNullOrWhiteSpace(keyId))
{
return "API key id is required.";
}
return keyId.Trim().All(character =>
char.IsAsciiLetterOrDigit(character)
|| character is '.' or '-')
? null
: "API key id may contain only letters, numbers, periods, and hyphens.";
}
private static string FormatApiKey(string keyId, string secret)
{
return $"mxgw_{keyId}_{secret}";
}
}
@@ -0,0 +1,12 @@
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed record DashboardApiKeySummary(
string KeyId,
string DisplayName,
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints,
DateTimeOffset CreatedUtc,
DateTimeOffset? LastUsedUtc,
DateTimeOffset? RevokedUtc);
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public static class DashboardAuthenticationDefaults
{
public const string AuthenticationScheme = "MxGateway.Dashboard";
public const string AuthorizationPolicy = "MxGateway.Dashboard";
public const string ScopeClaimType = "scope";
public const string LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
public const string CookieName = "__Host-MxGatewayDashboard";
}
@@ -0,0 +1,39 @@
using System.Security.Claims;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Result of a dashboard authentication attempt.
/// </summary>
public sealed record DashboardAuthenticationResult(
/// <summary>
/// Whether authentication succeeded.
/// </summary>
bool Succeeded,
/// <summary>
/// The authenticated principal if successful; otherwise null.
/// </summary>
ClaimsPrincipal? Principal,
/// <summary>
/// The failure message if authentication failed; otherwise null.
/// </summary>
string? FailureMessage)
{
/// <summary>
/// Creates a successful authentication result.
/// </summary>
/// <param name="principal">Authenticated principal.</param>
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
{
return new DashboardAuthenticationResult(true, principal, null);
}
/// <summary>
/// Creates a failed authentication result.
/// </summary>
/// <param name="failureMessage">Diagnostic message describing the failure.</param>
public static DashboardAuthenticationResult Fail(string failureMessage)
{
return new DashboardAuthenticationResult(false, null, failureMessage);
}
}
@@ -0,0 +1,266 @@
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using Novell.Directory.Ldap;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardAuthenticator(
IOptions<GatewayOptions> options,
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
{
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
/// <inheritdoc />
public async Task<DashboardAuthenticationResult> AuthenticateAsync(
string? username,
string? password,
CancellationToken cancellationToken)
{
LdapOptions ldapOptions = options.Value.Ldap;
if (!ldapOptions.Enabled
|| string.IsNullOrWhiteSpace(username)
|| string.IsNullOrWhiteSpace(password))
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
string normalizedUsername = username.Trim();
try
{
using LdapConnection connection = new();
connection.SecureSocketLayer = ldapOptions.UseTls;
await Task.Run(
() => connection.Connect(ldapOptions.Server, ldapOptions.Port),
cancellationToken)
.ConfigureAwait(false);
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
LdapEntry? candidate = await SearchUserAsync(
connection,
ldapOptions,
normalizedUsername,
cancellationToken)
.ConfigureAwait(false);
if (candidate is null)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
await Task.Run(
() => connection.Bind(candidate.Dn, password),
cancellationToken)
.ConfigureAwait(false);
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
LdapEntry? authenticatedEntry = await SearchUserAsync(
connection,
ldapOptions,
normalizedUsername,
cancellationToken)
.ConfigureAwait(false);
if (authenticatedEntry is null)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute)
?? normalizedUsername;
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
if (!IsMemberOfRequiredGroup(groups, ldapOptions.RequiredGroup))
{
logger.LogInformation(
"LDAP dashboard login denied for user {User}: missing required group {RequiredGroup}.",
normalizedUsername,
ldapOptions.RequiredGroup);
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
return DashboardAuthenticationResult.Success(CreatePrincipal(
normalizedUsername,
displayName,
groups));
}
catch (OperationCanceledException)
{
throw;
}
catch (LdapException ex)
{
logger.LogInformation(
"LDAP dashboard login rejected for user {User}: result code {ResultCode}.",
normalizedUsername,
ex.ResultCode);
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
}
internal static string EscapeLdapFilter(string value)
{
StringBuilder builder = new(value.Length);
foreach (char character in value)
{
builder.Append(character switch
{
'\\' => @"\5c",
'*' => @"\2a",
'(' => @"\28",
')' => @"\29",
'\0' => @"\00",
_ => character.ToString()
});
}
return builder.ToString();
}
internal static bool IsMemberOfRequiredGroup(IEnumerable<string> groups, string requiredGroup)
{
string normalizedRequiredGroup = requiredGroup.Trim();
if (string.IsNullOrWhiteSpace(normalizedRequiredGroup))
{
return false;
}
foreach (string group in groups)
{
string normalizedGroup = group.Trim();
if (string.Equals(normalizedGroup, normalizedRequiredGroup, StringComparison.OrdinalIgnoreCase)
|| string.Equals(
ExtractFirstRdnValue(normalizedGroup),
normalizedRequiredGroup,
StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
internal static string ExtractFirstRdnValue(string distinguishedName)
{
int equalsIndex = distinguishedName.IndexOf('=');
if (equalsIndex < 0)
{
return distinguishedName;
}
int valueStart = equalsIndex + 1;
int commaIndex = distinguishedName.IndexOf(',', valueStart);
return commaIndex > valueStart
? distinguishedName[valueStart..commaIndex]
: distinguishedName[valueStart..];
}
private static Task BindServiceAccountAsync(
LdapConnection connection,
LdapOptions ldapOptions,
CancellationToken cancellationToken)
{
return Task.Run(
() => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword),
cancellationToken);
}
private static async Task<LdapEntry?> SearchUserAsync(
LdapConnection connection,
LdapOptions ldapOptions,
string username,
CancellationToken cancellationToken)
{
string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})";
ILdapSearchResults results = await Task.Run(
() => connection.Search(
ldapOptions.SearchBase,
LdapConnection.ScopeSub,
filter,
attrs: null,
typesOnly: false),
cancellationToken)
.ConfigureAwait(false);
LdapEntry? entry = null;
while (results.HasMore())
{
LdapEntry next = results.Next();
if (entry is not null)
{
return null;
}
entry = next;
}
return entry;
}
private static string? ReadAttribute(LdapEntry entry, string attributeName)
{
return ReadLdapAttribute(entry, attributeName)?.StringValue;
}
private static IReadOnlyList<string> ReadAttributeValues(LdapEntry entry, string attributeName)
{
LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
return attribute?.StringValueArray ?? [];
}
private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName)
{
return entry.GetAttribute(attributeName)
?? entry.GetAttribute(attributeName.ToLowerInvariant())
?? entry.GetAttribute(attributeName.ToUpperInvariant());
}
private static ClaimsPrincipal CreatePrincipal(
string username,
string displayName,
IEnumerable<string> groups)
{
// CreatePrincipal is reached only after IsMemberOfRequiredGroup passed,
// so the authenticated user is authorized for the dashboard. Emit the
// admin scope claim that DashboardAuthorizationHandler checks when
// Dashboard:RequireAdminScope is enabled — without it, every LDAP login
// would be denied once route-level authorization is enforced.
List<Claim> claims =
[
new Claim(ClaimTypes.NameIdentifier, username),
new Claim(ClaimTypes.Name, displayName),
new Claim(DashboardAuthenticationDefaults.ScopeClaimType, GatewayScopes.Admin)
];
claims.AddRange(groups.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType,
group)));
ClaimsIdentity claimsIdentity = new(
claims,
DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
DashboardAuthenticationDefaults.LdapGroupClaimType);
return new ClaimsPrincipal(claimsIdentity);
}
}
@@ -0,0 +1,60 @@
using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardAuthorizationHandler(
IHttpContextAccessor httpContextAccessor,
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
{
/// <inheritdoc />
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
DashboardAuthorizationRequirement requirement)
{
GatewayOptions gatewayOptions = options.Value;
if (gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled)
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest())
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (context.User.Identity?.IsAuthenticated != true)
{
return Task.CompletedTask;
}
if (!gatewayOptions.Dashboard.RequireAdminScope || HasAdminScope(context))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
private bool IsLoopbackRequest()
{
IPAddress? remoteAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
return remoteAddress is not null && IPAddress.IsLoopback(remoteAddress);
}
private static bool HasAdminScope(AuthorizationHandlerContext context)
{
return context.User.HasClaim(
DashboardAuthenticationDefaults.ScopeClaimType,
GatewayScopes.Admin);
}
}
@@ -0,0 +1,5 @@
using Microsoft.AspNetCore.Authorization;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement;
@@ -0,0 +1,95 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// One node in the dashboard Browse hierarchy tree. Wraps a Galaxy object
/// and its child objects; the object's attributes are the leaf "tags" the
/// operator can right-click and add to the subscription panel.
/// </summary>
public sealed class DashboardBrowseNode
{
/// <summary>The underlying Galaxy object for this node.</summary>
public required GalaxyObject Object { get; init; }
/// <summary>Child objects contained by this object, sorted areas-first then by name.</summary>
public List<DashboardBrowseNode> Children { get; } = [];
/// <summary>The label shown for this node in the tree.</summary>
public string DisplayName =>
!string.IsNullOrWhiteSpace(Object.BrowseName) ? Object.BrowseName
: !string.IsNullOrWhiteSpace(Object.ContainedName) ? Object.ContainedName
: Object.TagName;
/// <summary>True when this node is a Galaxy area rather than an instance object.</summary>
public bool IsArea => Object.IsArea;
/// <summary>The object's attributes — the browsable tags.</summary>
public IReadOnlyList<GalaxyAttribute> Attributes => Object.Attributes;
/// <summary>True when the node has child objects or attributes to expand.</summary>
public bool HasChildren => Children.Count > 0 || Object.Attributes.Count > 0;
}
/// <summary>
/// Builds the dashboard Browse tree from the flat Galaxy object list held
/// by <c>IGalaxyHierarchyCache</c>. Pure and side-effect free so the
/// parent/child linkage and ordering rules are unit-testable.
/// </summary>
public static class DashboardBrowseTreeBuilder
{
/// <summary>Builds the root nodes of the Browse tree.</summary>
/// <param name="objects">The flat Galaxy object list.</param>
/// <returns>The root nodes, sorted areas-first then alphabetically.</returns>
public static IReadOnlyList<DashboardBrowseNode> Build(IReadOnlyList<GalaxyObject> objects)
{
ArgumentNullException.ThrowIfNull(objects);
Dictionary<int, DashboardBrowseNode> nodes = new(objects.Count);
foreach (GalaxyObject galaxyObject in objects)
{
// Last write wins on a duplicate gobject id — Galaxy ids are unique
// in practice, but guard so the dictionary build never throws.
nodes[galaxyObject.GobjectId] = new DashboardBrowseNode { Object = galaxyObject };
}
List<DashboardBrowseNode> roots = [];
foreach (DashboardBrowseNode node in nodes.Values)
{
int parentId = node.Object.ParentGobjectId;
if (parentId != 0
&& parentId != node.Object.GobjectId
&& nodes.TryGetValue(parentId, out DashboardBrowseNode? parent))
{
parent.Children.Add(node);
}
else
{
roots.Add(node);
}
}
SortRecursive(roots);
return roots;
}
private static void SortRecursive(List<DashboardBrowseNode> nodes)
{
nodes.Sort(CompareNodes);
foreach (DashboardBrowseNode node in nodes)
{
SortRecursive(node.Children);
}
}
// Areas sort before instance objects; within a group, by display name.
private static int CompareNodes(DashboardBrowseNode left, DashboardBrowseNode right)
{
if (left.IsArea != right.IsArea)
{
return left.IsArea ? -1 : 1;
}
return string.Compare(left.DisplayName, right.DisplayName, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,28 @@
using Microsoft.Data.SqlClient;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public static class DashboardConnectionStringDisplay
{
public static string GalaxyRepositoryConnectionString(string connectionString)
{
try
{
SqlConnectionStringBuilder builder = new(connectionString);
SqlConnectionStringBuilder display = new()
{
DataSource = builder.DataSource,
InitialCatalog = builder.InitialCatalog,
IntegratedSecurity = builder.IntegratedSecurity,
Encrypt = builder.Encrypt,
TrustServerCertificate = builder.TrustServerCertificate,
};
return display.ConnectionString;
}
catch (ArgumentException)
{
return "[invalid connection string]";
}
}
}
@@ -0,0 +1,218 @@
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http.HttpResults;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
public static class DashboardEndpointRouteBuilderExtensions
{
/// <summary>Maps all gateway dashboard routes including login, logout, and Razor components.</summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <returns>The route builder for chaining.</returns>
public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints)
{
IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService<IConfiguration>();
IConfigurationSection dashboardSection = configuration
.GetSection($"{GatewayOptions.SectionName}:Dashboard");
if (bool.TryParse(dashboardSection["Enabled"], out bool enabled) && !enabled)
{
return endpoints;
}
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase);
RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase);
dashboard.MapGet(
"/login",
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase))
.AllowAnonymous()
.WithName("DashboardLogin");
dashboard.MapPost(
"/login",
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
PostLoginAsync(httpContext, antiforgery, authenticator, pathBase))
.AllowAnonymous()
.WithName("DashboardLoginPost");
dashboard.MapPost(
"/logout",
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase))
.AllowAnonymous()
.WithName("DashboardLogout");
dashboard.MapGet("/denied", () => Results.Content(
RenderPage("Access denied", "<p>The signed-in user is not authorized for dashboard access.</p>"),
"text/html"))
.AllowAnonymous()
.WithName("DashboardAccessDenied");
// Every dashboard Razor component requires an authorized session. The
// login/logout/denied endpoints above opt out via AllowAnonymous(); an
// unauthenticated request to a component route is challenged by the
// cookie scheme and redirected to the login page.
dashboard.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
return endpoints;
}
private static Task<ContentHttpResult> GetLoginAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
string pathBase)
{
string returnUrl = SanitizeReturnUrl(
httpContext.Request.Query["returnUrl"].ToString(),
pathBase);
return Task.FromResult(TypedResults.Content(
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null),
"text/html"));
}
private static async Task<IResult> PostLoginAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
IDashboardAuthenticator authenticator,
string pathBase)
{
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
IFormCollection form = await httpContext.Request
.ReadFormAsync(httpContext.RequestAborted)
.ConfigureAwait(false);
string returnUrl = SanitizeReturnUrl(
form["returnUrl"].ToString(),
pathBase);
DashboardAuthenticationResult result = await authenticator
.AuthenticateAsync(
form["username"].ToString(),
form["password"].ToString(),
httpContext.RequestAborted)
.ConfigureAwait(false);
if (!result.Succeeded || result.Principal is null)
{
return TypedResults.Content(
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, result.FailureMessage),
"text/html",
statusCode: StatusCodes.Status401Unauthorized);
}
await httpContext
.SignInAsync(DashboardAuthenticationDefaults.AuthenticationScheme, result.Principal)
.ConfigureAwait(false);
return Results.LocalRedirect(returnUrl);
}
private static async Task<IResult> PostLogoutAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
string pathBase)
{
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
await httpContext
.SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
.ConfigureAwait(false);
return Results.LocalRedirect($"{pathBase}/login");
}
private static string RenderLoginPage(
HttpContext httpContext,
IAntiforgery antiforgery,
string returnUrl,
string pathBase,
string? failureMessage)
{
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
string requestToken = tokens.RequestToken ?? string.Empty;
string alert = string.IsNullOrWhiteSpace(failureMessage)
? string.Empty
: $"<p class=\"alert alert-danger\" role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
string body = $"""
<section class="dashboard-login">
{alert}
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}" class="card login-card">
<div class="card-body">
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" name="username" type="text" autocomplete="username" class="form-control" />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Sign in</button>
</div>
</form>
</section>
""";
return RenderPage("Dashboard Sign In", body);
}
private static string RenderPage(string title, string body)
{
return $"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{HtmlEncoder.Default.Encode(title)}</title>
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/dashboard.css" />
</head>
<body class="dashboard-body">
<header class="app-bar">
<span class="brand"><span class="mark">&#9646;</span> MXAccess Gateway</span>
</header>
<main class="page">
<div class="dashboard-page-header">
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
</div>
{body}
</main>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
</body>
</html>
""";
}
private static string NormalizePathBase(string pathBase)
{
string normalized = pathBase.TrimEnd('/');
return string.IsNullOrWhiteSpace(normalized) || !normalized.StartsWith("/", StringComparison.Ordinal)
? "/dashboard"
: normalized;
}
private static string SanitizeReturnUrl(string? returnUrl, string pathBase)
{
if (string.IsNullOrWhiteSpace(returnUrl)
|| !returnUrl.StartsWith("/", StringComparison.Ordinal)
|| returnUrl.StartsWith("//", StringComparison.Ordinal)
|| !returnUrl.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)
|| Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
{
return pathBase;
}
return returnUrl;
}
}
@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed record DashboardFaultSummary(
string Source,
string? SessionId,
int? WorkerProcessId,
string State,
string Message,
DateTimeOffset ObservedAt);
@@ -0,0 +1,12 @@
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>Projects the precomputed Galaxy cache dashboard summary.</summary>
internal static class DashboardGalaxyProjector
{
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
{
return entry.DashboardSummary;
}
}
@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Snapshot of the Galaxy Repository (ZB) browse state surfaced on the dashboard.
/// Populated by <see cref="GalaxySummaryCache"/> on a background refresh cadence so
/// the dashboard never blocks on SQL.
/// </summary>
public sealed record DashboardGalaxySummary(
DashboardGalaxyStatus Status,
DateTimeOffset? LastQueriedAt,
DateTimeOffset? LastSuccessAt,
DateTimeOffset? LastDeployTime,
string? LastError,
int ObjectCount,
int AreaCount,
int AttributeCount,
int HistorizedAttributeCount,
int AlarmAttributeCount,
IReadOnlyList<DashboardGalaxyTemplateUsage> TopTemplates,
IReadOnlyList<DashboardGalaxyCategoryCount> ObjectCategories)
{
/// <summary>Gets the unknown Galaxy status placeholder.</summary>
public static DashboardGalaxySummary Unknown { get; } = new(
DashboardGalaxyStatus.Unknown,
LastQueriedAt: null,
LastSuccessAt: null,
LastDeployTime: null,
LastError: null,
ObjectCount: 0,
AreaCount: 0,
AttributeCount: 0,
HistorizedAttributeCount: 0,
AlarmAttributeCount: 0,
TopTemplates: Array.Empty<DashboardGalaxyTemplateUsage>(),
ObjectCategories: Array.Empty<DashboardGalaxyCategoryCount>());
}
public enum DashboardGalaxyStatus
{
Unknown = 0,
Healthy = 1,
Stale = 2,
Unavailable = 3,
}
public sealed record DashboardGalaxyTemplateUsage(string TemplateName, int InstanceCount);
public sealed record DashboardGalaxyCategoryCount(int CategoryId, string CategoryName, int ObjectCount);
@@ -0,0 +1,213 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Sessions;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Default <see cref="IDashboardLiveDataService"/>. Owns one shared gateway
/// session for the whole dashboard: it is opened lazily on first use and
/// re-opened transparently whenever it faults, is closed, or its lease
/// expires. All access is serialised through <see cref="_gate"/> so the
/// single backing worker only ever sees one in-flight command.
/// </summary>
public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsyncDisposable
{
private const string BackendName = "Galaxy";
private const string ClientName = "mxgateway-dashboard";
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5);
private readonly ISessionManager _sessionManager;
private readonly IGatewayAlarmService _alarmService;
private readonly ILogger<DashboardLiveDataService> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly HashSet<string> _subscribed = new(StringComparer.OrdinalIgnoreCase);
private GatewaySession? _session;
private int _serverHandle;
private bool _disposed;
/// <summary>Initializes the live-data service.</summary>
/// <param name="sessionManager">Gateway session manager.</param>
/// <param name="alarmService">Gateway central alarm service.</param>
/// <param name="logger">Diagnostic logger.</param>
public DashboardLiveDataService(
ISessionManager sessionManager,
IGatewayAlarmService alarmService,
ILogger<DashboardLiveDataService> logger)
{
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
_alarmService = alarmService ?? throw new ArgumentNullException(nameof(alarmService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<DashboardLiveReadResult> ReadAsync(
IReadOnlyCollection<string> tagAddresses,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(tagAddresses);
if (tagAddresses.Count == 0)
{
return DashboardLiveReadResult.Empty;
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
(GatewaySession session, int serverHandle) = await EnsureReadyAsync(cancellationToken)
.ConfigureAwait(false);
string[] toSubscribe = tagAddresses.Where(tag => !_subscribed.Contains(tag)).ToArray();
if (toSubscribe.Length > 0)
{
await session.SubscribeBulkAsync(serverHandle, toSubscribe, cancellationToken)
.ConfigureAwait(false);
foreach (string tag in toSubscribe)
{
_subscribed.Add(tag);
}
}
IReadOnlyList<BulkReadResult> results = await session
.ReadBulkAsync(serverHandle, tagAddresses.ToArray(), ReadTimeout, cancellationToken)
.ConfigureAwait(false);
DashboardTagValue[] values = results
.Select(DashboardTagValue.FromBulkReadResult)
.ToArray();
return new DashboardLiveReadResult(values, null, session.SessionId, session.WorkerProcessId);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
InvalidateSession();
_logger.LogWarning(exception, "Dashboard live read failed; the dashboard session will be re-opened.");
return new DashboardLiveReadResult([], exception.Message, null, null);
}
finally
{
_gate.Release();
}
}
/// <inheritdoc />
public Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken)
{
// Alarms come from the gateway's always-on central monitor; the
// dashboard reads its in-process cache directly — no session needed.
DashboardActiveAlarm[] alarms = _alarmService.CurrentAlarms
.Select(DashboardActiveAlarm.FromSnapshot)
.ToArray();
string? error = _alarmService.State is GatewayAlarmMonitorState.Monitoring
or GatewayAlarmMonitorState.Disabled
? null
: _alarmService.LastError ?? $"Alarm monitor is {_alarmService.State}.";
return Task.FromResult(new DashboardAlarmQueryResult(alarms, error, _alarmService.WorkerProcessId));
}
// Returns a Ready session + its Register server handle, opening a fresh
// session when none exists or the current one is no longer usable. Callers
// must hold _gate.
private async Task<(GatewaySession Session, int ServerHandle)> EnsureReadyAsync(
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
GatewaySession? existing = _session;
if (existing is not null
&& existing.State == SessionState.Ready
&& _sessionManager.TryGetSession(existing.SessionId, out _))
{
return (existing, _serverHandle);
}
if (existing is not null)
{
_logger.LogInformation(
"Dashboard session {SessionId} is no longer usable (state {State}); re-opening.",
existing.SessionId,
existing.State);
await CloseQuietlyAsync(existing.SessionId).ConfigureAwait(false);
}
_subscribed.Clear();
_session = null;
GatewaySession session = await _sessionManager.OpenSessionAsync(
new SessionOpenRequest(BackendName, ClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null),
ClientName,
cancellationToken)
.ConfigureAwait(false);
WorkerCommandReply reply = await session.InvokeAsync(
new WorkerCommand
{
Command = new MxCommand
{
Kind = MxCommandKind.Register,
Register = new RegisterCommand { ClientName = ClientName },
},
},
cancellationToken)
.ConfigureAwait(false);
int? serverHandle = reply.Reply?.Register?.ServerHandle;
if (serverHandle is null)
{
string diagnostic = reply.Reply?.ProtocolStatus?.Message
?? reply.Reply?.DiagnosticMessage
?? "Worker did not return a server handle for Register.";
await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false);
throw new InvalidOperationException($"Dashboard session registration failed: {diagnostic}");
}
_session = session;
_serverHandle = serverHandle.Value;
_logger.LogInformation(
"Dashboard session {SessionId} opened (worker pid {WorkerPid}).",
session.SessionId,
session.WorkerProcessId);
return (session, _serverHandle);
}
// Drops the cached session so the next call re-opens. Callers must hold _gate.
private void InvalidateSession()
{
_session = null;
_serverHandle = 0;
_subscribed.Clear();
}
private async Task CloseQuietlyAsync(string sessionId)
{
try
{
await _sessionManager.CloseSessionAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "Closing stale dashboard session {SessionId} failed.", sessionId);
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
GatewaySession? session = _session;
_session = null;
if (session is not null)
{
await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false);
}
_gate.Dispose();
}
}
@@ -0,0 +1,6 @@
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed record DashboardMetricSummary(
string Name,
long Value,
string? Dimension = null);
@@ -0,0 +1,110 @@
using System.Globalization;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Formats an <see cref="MxValue"/> into the short, human-readable text the
/// dashboard's Browse subscription panel shows. Kept separate from the
/// view layer so the formatting rules are unit-testable without a worker.
/// </summary>
public static class DashboardMxValueFormatter
{
/// <summary>Maximum array elements rendered inline before the value is truncated.</summary>
private const int MaxArrayElements = 24;
/// <summary>Formats the value payload of an <see cref="MxValue"/>.</summary>
/// <param name="value">The value to format; may be null.</param>
/// <returns>A display string — never null.</returns>
public static string FormatValue(MxValue? value)
{
if (value is null)
{
return "-";
}
if (value.IsNull)
{
return "(null)";
}
return value.KindCase switch
{
MxValue.KindOneofCase.BoolValue => value.BoolValue ? "true" : "false",
MxValue.KindOneofCase.Int32Value => value.Int32Value.ToString(CultureInfo.InvariantCulture),
MxValue.KindOneofCase.Int64Value => value.Int64Value.ToString(CultureInfo.InvariantCulture),
MxValue.KindOneofCase.FloatValue => value.FloatValue.ToString("G7", CultureInfo.InvariantCulture),
MxValue.KindOneofCase.DoubleValue => value.DoubleValue.ToString("G15", CultureInfo.InvariantCulture),
MxValue.KindOneofCase.StringValue => value.StringValue,
MxValue.KindOneofCase.TimestampValue => value.TimestampValue
.ToDateTimeOffset()
.UtcDateTime
.ToString("yyyy-MM-dd HH:mm:ss.fff 'UTC'", CultureInfo.InvariantCulture),
MxValue.KindOneofCase.ArrayValue => FormatArray(value.ArrayValue),
MxValue.KindOneofCase.RawValue => $"({value.RawValue.Length} bytes)",
_ => "-",
};
}
/// <summary>Formats the MXAccess data type of an <see cref="MxValue"/>.</summary>
/// <param name="value">The value whose data type to describe; may be null.</param>
/// <returns>The data-type name — never null. Arrays render as <c>Element[dims]</c>.</returns>
public static string FormatDataType(MxValue? value)
{
if (value is null)
{
return "-";
}
// A scalar carries its type in MxValue.DataType, but an array leaves
// that Unspecified and carries the element type on the MxArray itself.
return value.KindCase == MxValue.KindOneofCase.ArrayValue
? FormatArrayDataType(value.ArrayValue)
: value.DataType.ToString();
}
private static string FormatArrayDataType(MxArray array)
{
string dimensions = array.Dimensions.Count > 0
? string.Join(",", array.Dimensions)
: string.Empty;
return $"{array.ElementDataType}[{dimensions}]";
}
private static string FormatArray(MxArray array)
{
IReadOnlyList<string> elements = array.ValuesCase switch
{
MxArray.ValuesOneofCase.BoolValues =>
array.BoolValues.Values.Select(item => item ? "true" : "false").ToArray(),
MxArray.ValuesOneofCase.Int32Values =>
array.Int32Values.Values.Select(item => item.ToString(CultureInfo.InvariantCulture)).ToArray(),
MxArray.ValuesOneofCase.Int64Values =>
array.Int64Values.Values.Select(item => item.ToString(CultureInfo.InvariantCulture)).ToArray(),
MxArray.ValuesOneofCase.FloatValues =>
array.FloatValues.Values.Select(item => item.ToString("G7", CultureInfo.InvariantCulture)).ToArray(),
MxArray.ValuesOneofCase.DoubleValues =>
array.DoubleValues.Values.Select(item => item.ToString("G15", CultureInfo.InvariantCulture)).ToArray(),
MxArray.ValuesOneofCase.StringValues =>
array.StringValues.Values.Select(item => $"\"{item}\"").ToArray(),
MxArray.ValuesOneofCase.TimestampValues =>
array.TimestampValues.Values
.Select(item => item.ToDateTimeOffset().UtcDateTime
.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture))
.ToArray(),
MxArray.ValuesOneofCase.RawValues =>
array.RawValues.Values.Select(item => $"({item.Length} bytes)").ToArray(),
_ => [],
};
if (elements.Count == 0)
{
return "[]";
}
string body = string.Join(", ", elements.Take(MaxArrayElements));
return elements.Count > MaxArrayElements
? $"[{body}, … {elements.Count} total]"
: $"[{body}]";
}
}
@@ -0,0 +1,39 @@
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
internal static class DashboardRedactor
{
private static readonly string[] SensitiveTextMarkers =
[
"apikey",
"api_key",
"authorization",
"credential",
"password",
"secret",
"token",
];
/// <summary>
/// Redacts sensitive content from a value for dashboard display.
/// </summary>
/// <param name="value">Value to redact.</param>
/// <returns>Redacted value or original value if not sensitive.</returns>
public static string? Redact(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
if (value.Contains("mxgw_", StringComparison.OrdinalIgnoreCase))
{
return GatewayLogRedactor.RedactClientIdentity(value);
}
return SensitiveTextMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase))
? GatewayLogRedactor.RedactedValue
: value;
}
}
@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Extension methods for configuring the gateway dashboard services.
/// </summary>
public static class DashboardServiceCollectionExtensions
{
/// <summary>
/// Registers all dashboard services, authentication, and Razor components.
/// </summary>
/// <param name="services">Service collection to register services.</param>
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
{
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
services.AddSingleton<DashboardApiKeyAuthorization>();
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
services.AddHttpContextAccessor();
services.AddAntiforgery();
services.AddCascadingAuthenticationState();
services.AddRazorComponents()
.AddInteractiveServerComponents();
services
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<GatewayOptions>>(ConfigureCookieOptions);
services.AddAuthorization(options =>
{
options.AddPolicy(
DashboardAuthenticationDefaults.AuthorizationPolicy,
policy => policy.AddRequirements(new DashboardAuthorizationRequirement()));
});
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
return services;
}
private static void ConfigureCookieOptions(
CookieAuthenticationOptions cookieOptions,
IOptions<GatewayOptions> gatewayOptions)
{
string pathBase = gatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase))
{
pathBase = "/dashboard";
}
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
cookieOptions.Cookie.HttpOnly = true;
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
cookieOptions.Cookie.Path = "/";
cookieOptions.LoginPath = $"{pathBase}/login";
cookieOptions.LogoutPath = $"{pathBase}/logout";
cookieOptions.AccessDeniedPath = $"{pathBase}/denied";
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
cookieOptions.SlidingExpiration = true;
}
}
@@ -0,0 +1,20 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed record DashboardSessionSummary(
string SessionId,
string BackendName,
SessionState State,
string? ClientIdentity,
string? ClientSessionName,
string? ClientCorrelationId,
DateTimeOffset OpenedAt,
DateTimeOffset LastClientActivityAt,
DateTimeOffset? LeaseExpiresAt,
int? WorkerProcessId,
WorkerClientState? WorkerState,
DateTimeOffset? LastWorkerHeartbeatAt,
long EventsReceived,
string? LastFault);
@@ -0,0 +1,17 @@
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed record DashboardSnapshot(
DateTimeOffset GeneratedAt,
DateTimeOffset GatewayStartedAt,
TimeSpan GatewayUptime,
string GatewayStatus,
string GatewayVersion,
IReadOnlyList<DashboardSessionSummary> Sessions,
IReadOnlyList<DashboardWorkerSummary> Workers,
IReadOnlyList<DashboardMetricSummary> Metrics,
IReadOnlyList<DashboardFaultSummary> Faults,
IReadOnlyList<DashboardApiKeySummary> ApiKeys,
EffectiveGatewayConfiguration Configuration,
DashboardGalaxySummary Galaxy);
@@ -0,0 +1,282 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardSnapshotService : IDashboardSnapshotService
{
private const string HealthyStatus = "Healthy";
private readonly ISessionRegistry _sessionRegistry;
private readonly GatewayMetrics _metrics;
private readonly IGatewayConfigurationProvider _configurationProvider;
private readonly IGalaxyHierarchyCache _galaxyHierarchyCache;
private readonly IApiKeyAdminStore _apiKeyAdminStore;
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _gatewayStartedAt;
private readonly TimeSpan _snapshotInterval;
private readonly TimeSpan _apiKeySummaryRefreshTimeout = TimeSpan.FromSeconds(2);
private readonly int _recentFaultLimit;
private readonly int _recentSessionLimit;
private readonly ILogger<DashboardSnapshotService> _logger;
private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1);
private IReadOnlyList<DashboardApiKeySummary> _apiKeySummaries = Array.Empty<DashboardApiKeySummary>();
/// <summary>Initializes a new instance of the DashboardSnapshotService class.</summary>
/// <param name="sessionRegistry">Registry of active gateway sessions.</param>
/// <param name="metrics">Gateway metrics collector.</param>
/// <param name="configurationProvider">Gateway configuration provider.</param>
/// <param name="galaxyHierarchyCache">Galaxy hierarchy cache.</param>
/// <param name="options">Gateway configuration options.</param>
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
public DashboardSnapshotService(
ISessionRegistry sessionRegistry,
GatewayMetrics metrics,
IGatewayConfigurationProvider configurationProvider,
IGalaxyHierarchyCache galaxyHierarchyCache,
IApiKeyAdminStore apiKeyAdminStore,
IOptions<GatewayOptions> options,
TimeProvider? timeProvider = null,
ILogger<DashboardSnapshotService>? logger = null)
{
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_configurationProvider = configurationProvider ?? throw new ArgumentNullException(nameof(configurationProvider));
_galaxyHierarchyCache = galaxyHierarchyCache ?? throw new ArgumentNullException(nameof(galaxyHierarchyCache));
_apiKeyAdminStore = apiKeyAdminStore ?? throw new ArgumentNullException(nameof(apiKeyAdminStore));
ArgumentNullException.ThrowIfNull(options);
_timeProvider = timeProvider ?? TimeProvider.System;
_gatewayStartedAt = _timeProvider.GetUtcNow();
_snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds);
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
}
/// <summary>
/// Gets a current dashboard snapshot of gateway state.
/// </summary>
/// <returns>Dashboard snapshot.</returns>
public DashboardSnapshot GetSnapshot()
{
DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
IReadOnlyList<GatewaySession> sessions = _sessionRegistry.Snapshot()
.OrderByDescending(session => session.OpenedAt)
.ToArray();
GatewayMetricsSnapshot metricsSnapshot = _metrics.GetSnapshot();
IReadOnlyList<DashboardSessionSummary> sessionSummaries = sessions
.Take(ResolveLimit(_recentSessionLimit))
.Select(session => CreateSessionSummary(session, metricsSnapshot))
.ToArray();
IReadOnlyList<DashboardWorkerSummary> workerSummaries = sessions
.Where(session => session.WorkerClient is { State: not WorkerClientState.Closed })
.Select(CreateWorkerSummary)
.ToArray();
return new DashboardSnapshot(
GeneratedAt: generatedAt,
GatewayStartedAt: _gatewayStartedAt,
GatewayUptime: generatedAt - _gatewayStartedAt,
GatewayStatus: HealthyStatus,
GatewayVersion: typeof(DashboardSnapshotService).Assembly.GetName().Version?.ToString() ?? "unknown",
Sessions: sessionSummaries,
Workers: workerSummaries,
Metrics: CreateMetricSummaries(metricsSnapshot),
Faults: CreateFaultSummaries(sessions, generatedAt),
ApiKeys: Volatile.Read(ref _apiKeySummaries),
Configuration: _configurationProvider.GetEffectiveConfiguration(),
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
}
/// <summary>
/// Watches dashboard snapshots at regular intervals asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of dashboard snapshots.</returns>
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
yield break;
}
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
yield return GetSnapshot();
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
while (true)
{
bool hasNext;
try
{
hasNext = await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
yield break;
}
if (!hasNext)
{
yield break;
}
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
yield return GetSnapshot();
}
}
private static DashboardSessionSummary CreateSessionSummary(
GatewaySession session,
GatewayMetricsSnapshot metricsSnapshot)
{
IWorkerClient? workerClient = session.WorkerClient;
metricsSnapshot.EventsBySession.TryGetValue(session.SessionId, out long eventsReceived);
return new DashboardSessionSummary(
SessionId: session.SessionId,
BackendName: session.BackendName,
State: session.State,
ClientIdentity: DashboardRedactor.Redact(session.ClientIdentity),
ClientSessionName: DashboardRedactor.Redact(session.ClientSessionName),
ClientCorrelationId: DashboardRedactor.Redact(session.ClientCorrelationId),
OpenedAt: session.OpenedAt,
LastClientActivityAt: session.LastClientActivityAt,
LeaseExpiresAt: session.LeaseExpiresAt,
WorkerProcessId: workerClient?.ProcessId,
WorkerState: workerClient?.State,
LastWorkerHeartbeatAt: workerClient?.LastHeartbeatAt,
EventsReceived: eventsReceived,
LastFault: DashboardRedactor.Redact(session.FinalFault));
}
private static DashboardWorkerSummary CreateWorkerSummary(GatewaySession session)
{
IWorkerClient workerClient = session.WorkerClient!;
return new DashboardWorkerSummary(
SessionId: session.SessionId,
ProcessId: workerClient.ProcessId,
State: workerClient.State,
LastHeartbeatAt: workerClient.LastHeartbeatAt,
LastFault: DashboardRedactor.Redact(session.FinalFault));
}
private static IReadOnlyList<DashboardMetricSummary> CreateMetricSummaries(GatewayMetricsSnapshot snapshot)
{
List<DashboardMetricSummary> metrics =
[
new("mxgateway.sessions.open", snapshot.OpenSessions),
new("mxgateway.workers.running", snapshot.WorkersRunning),
new("mxgateway.events.worker_queue.depth", snapshot.WorkerEventQueueDepth),
new("mxgateway.events.grpc_stream_queue.depth", snapshot.GrpcEventStreamQueueDepth),
new("mxgateway.sessions.opened", snapshot.SessionsOpened),
new("mxgateway.sessions.closed", snapshot.SessionsClosed),
new("mxgateway.commands.started", snapshot.CommandsStarted),
new("mxgateway.commands.succeeded", snapshot.CommandsSucceeded),
new("mxgateway.commands.failed", snapshot.CommandsFailed),
new("mxgateway.events.received", snapshot.EventsReceived),
new("mxgateway.queues.overflows", snapshot.QueueOverflows),
new("mxgateway.faults", snapshot.Faults),
new("mxgateway.workers.killed", snapshot.WorkerKills),
new("mxgateway.workers.exited", snapshot.WorkerExits),
new("mxgateway.heartbeats.failed", snapshot.HeartbeatFailures),
new("mxgateway.grpc.streams.disconnected", snapshot.StreamDisconnects),
];
metrics.AddRange(snapshot.CommandFailuresByMethod
.OrderBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
.Select(entry => new DashboardMetricSummary("mxgateway.commands.failed", entry.Value, entry.Key)));
metrics.AddRange(snapshot.EventsByFamily
.OrderBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
.Select(entry => new DashboardMetricSummary("mxgateway.events.received", entry.Value, entry.Key)));
return metrics;
}
private IReadOnlyList<DashboardFaultSummary> CreateFaultSummaries(
IReadOnlyList<GatewaySession> sessions,
DateTimeOffset generatedAt)
{
return sessions
.Where(HasFault)
.Take(ResolveLimit(_recentFaultLimit))
.Select(session => new DashboardFaultSummary(
Source: session.WorkerClient?.State == WorkerClientState.Faulted ? "Worker" : "Session",
SessionId: session.SessionId,
WorkerProcessId: session.WorkerProcessId,
State: session.WorkerClient?.State == WorkerClientState.Faulted
? WorkerClientState.Faulted.ToString()
: session.State.ToString(),
Message: DashboardRedactor.Redact(session.FinalFault) ?? "Faulted",
ObservedAt: generatedAt))
.ToArray();
}
private async Task RefreshApiKeySummariesAsync(CancellationToken cancellationToken)
{
if (!await _apiKeySummaryRefreshGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return;
}
try
{
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(_apiKeySummaryRefreshTimeout);
IReadOnlyList<DashboardApiKeySummary> summaries = (await _apiKeyAdminStore.ListAsync(timeout.Token)
.ConfigureAwait(false))
.Select(key => new DashboardApiKeySummary(
KeyId: key.KeyId,
DisplayName: key.DisplayName,
Scopes: key.Scopes,
Constraints: key.Constraints,
CreatedUtc: key.CreatedUtc,
LastUsedUtc: key.LastUsedUtc,
RevokedUtc: key.RevokedUtc))
.ToArray();
Volatile.Write(ref _apiKeySummaries, summaries);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
_logger.LogWarning(
"Timed out refreshing dashboard API key summaries after {Timeout}.",
_apiKeySummaryRefreshTimeout);
}
catch (Exception)
{
_logger.LogWarning("Failed to refresh dashboard API key summaries.");
}
finally
{
_apiKeySummaryRefreshGate.Release();
}
}
private static bool HasFault(GatewaySession session)
{
return session.State == ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Faulted
|| session.WorkerClient?.State == WorkerClientState.Faulted
|| !string.IsNullOrWhiteSpace(session.FinalFault);
}
private static int ResolveLimit(int configuredLimit)
{
return configuredLimit < 0 ? 0 : configuredLimit;
}
}
@@ -0,0 +1,69 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// One live tag value as shown in the Browse subscription panel. Projected
/// from a worker <see cref="BulkReadResult"/> so the Razor component never
/// touches protobuf types directly.
/// </summary>
public sealed record DashboardTagValue(
string TagAddress,
bool Ok,
string ValueText,
string DataType,
int Quality,
bool QualityGood,
DateTimeOffset? SourceTimestamp,
string? Error)
{
/// <summary>
/// Classic OPC-DA "Good" quality. MXAccess surfaces 192 for a healthy
/// advised value; anything lower is uncertain or bad.
/// </summary>
private const int GoodQualityThreshold = 192;
/// <summary>Projects a worker bulk-read result into a dashboard tag value.</summary>
/// <param name="result">The per-tag result from a <c>ReadBulk</c> reply.</param>
/// <returns>The projected dashboard value.</returns>
public static DashboardTagValue FromBulkReadResult(BulkReadResult result)
{
ArgumentNullException.ThrowIfNull(result);
string? error = null;
if (!result.WasSuccessful)
{
error = !string.IsNullOrWhiteSpace(result.ErrorMessage)
? result.ErrorMessage
: FirstStatusDiagnostic(result);
}
return new DashboardTagValue(
TagAddress: result.TagAddress,
Ok: result.WasSuccessful,
ValueText: DashboardMxValueFormatter.FormatValue(result.Value),
DataType: DashboardMxValueFormatter.FormatDataType(result.Value),
Quality: result.Quality,
QualityGood: result.WasSuccessful && result.Quality >= GoodQualityThreshold,
SourceTimestamp: result.SourceTimestamp?.ToDateTimeOffset(),
Error: error);
}
private static string? FirstStatusDiagnostic(BulkReadResult result)
{
foreach (MxStatusProxy status in result.Statuses)
{
if (!string.IsNullOrWhiteSpace(status.DiagnosticText))
{
return status.DiagnosticText;
}
if (status.Category != MxStatusCategory.Ok)
{
return status.Category.ToString();
}
}
return null;
}
}
@@ -0,0 +1,10 @@
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed record DashboardWorkerSummary(
string SessionId,
int? ProcessId,
WorkerClientState State,
DateTimeOffset LastHeartbeatAt,
string? LastFault);
@@ -0,0 +1,23 @@
using System.Security.Claims;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public interface IDashboardApiKeyManagementService
{
bool CanManage(ClaimsPrincipal user);
Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
CancellationToken cancellationToken);
Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken);
Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken);
}
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Authenticates dashboard access with API keys.
/// </summary>
public interface IDashboardAuthenticator
{
/// <summary>
/// Authenticates the dashboard session with an API key.
/// </summary>
/// <param name="apiKey">The API key to authenticate.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<DashboardAuthenticationResult> AuthenticateAsync(
string? username,
string? password,
CancellationToken cancellationToken);
}
@@ -0,0 +1,47 @@
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Supplies the Browse and Alarms dashboard tabs with live MXAccess data.
/// Owns one shared, lazily-opened gateway session (and therefore one
/// worker process) used by every dashboard circuit; the session is
/// transparently re-opened if it faults or its lease expires.
/// </summary>
public interface IDashboardLiveDataService
{
/// <summary>
/// Subscribes (once) and reads the current value, quality and timestamp
/// of the supplied tags. Never throws — transport and session failures
/// are surfaced in <see cref="DashboardLiveReadResult.Error"/>.
/// </summary>
/// <param name="tagAddresses">Fully-qualified tag references to read.</param>
/// <param name="cancellationToken">Token to cancel the read.</param>
/// <returns>The read result, or an error-bearing result on failure.</returns>
Task<DashboardLiveReadResult> ReadAsync(
IReadOnlyCollection<string> tagAddresses,
CancellationToken cancellationToken);
/// <summary>
/// Queries the currently-active alarm set for the dashboard session.
/// Never throws — failures are surfaced in
/// <see cref="DashboardAlarmQueryResult.Error"/>.
/// </summary>
/// <param name="cancellationToken">Token to cancel the query.</param>
/// <returns>The active alarms, or an error-bearing result on failure.</returns>
Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken);
}
/// <summary>Result of a dashboard live tag read.</summary>
/// <param name="Values">The per-tag values, or an empty list on error.</param>
/// <param name="Error">A diagnostic message when the read failed; otherwise null.</param>
/// <param name="SessionId">The dashboard session id used, when available.</param>
/// <param name="WorkerProcessId">The worker process id backing the session, when available.</param>
public sealed record DashboardLiveReadResult(
IReadOnlyList<DashboardTagValue> Values,
string? Error,
string? SessionId,
int? WorkerProcessId)
{
/// <summary>An empty, successful result — used when no tags are subscribed.</summary>
public static DashboardLiveReadResult Empty { get; } =
new([], null, null, null);
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Provides snapshots of the dashboard state for UI updates.
/// </summary>
public interface IDashboardSnapshotService
{
/// <summary>
/// Gets the current dashboard snapshot.
/// </summary>
DashboardSnapshot GetSnapshot();
/// <summary>
/// Watches for changes to the dashboard state as an async enumerable.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
}