Add Galaxy repository API and clients

This commit is contained in:
Joseph Doherty
2026-04-29 07:27:00 -04:00
parent 047d875fe6
commit 133c83029b
103 changed files with 22788 additions and 39 deletions
@@ -23,6 +23,9 @@
<li class="nav-item">
<NavLink class="nav-link" href="events">Events</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="galaxy">Galaxy</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="settings">Settings</NavLink>
</li>
@@ -29,6 +29,26 @@ else
<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>
@@ -36,3 +56,23 @@ else
<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,195 @@
@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()" />
<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)" />
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@LastAttemptDetail()" />
</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<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() =>
DashboardRedactor.Redact(GalaxyOptions.Value.ConnectionString);
}
@@ -9,7 +9,9 @@
"Ready" or "Healthy" => "text-bg-success",
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "text-bg-info",
"Closed" => "text-bg-secondary",
"Faulted" => "text-bg-danger",
"Stale" => "text-bg-warning",
"Faulted" or "Unavailable" => "text-bg-danger",
"Unknown" => "text-bg-light text-dark border",
_ => "text-bg-light text-dark border"
};
}
@@ -0,0 +1,98 @@
using MxGateway.Server.Galaxy;
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Projects a <see cref="GalaxyHierarchyCacheEntry"/> into a
/// <see cref="DashboardGalaxySummary"/> for the Blazor pages. Top-templates and
/// per-category breakdowns are computed here rather than stored on the cache so the
/// Galaxy namespace stays free of dashboard-presentation concepts.
/// </summary>
internal static class DashboardGalaxyProjector
{
private const int TopTemplatesLimit = 10;
private static readonly IReadOnlyDictionary<int, string> CategoryNamesById = new Dictionary<int, string>
{
[1] = "WinPlatform",
[3] = "AppEngine",
[4] = "InTouchViewApp",
[10] = "UserDefined",
[11] = "FieldReference",
[13] = "Area",
[17] = "DIObject",
[24] = "DDESuiteLinkClient",
[26] = "OPCClient",
};
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
{
DashboardGalaxyStatus status = entry.Status switch
{
GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy,
GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale,
GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable,
_ => DashboardGalaxyStatus.Unknown,
};
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
if (entry.Hierarchy.Count == 0)
{
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
}
else
{
Dictionary<int, int> objectsByCategory = new();
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
foreach (GalaxyHierarchyRow row in entry.Hierarchy)
{
objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount);
objectsByCategory[row.CategoryId] = categoryCount + 1;
if (row.TemplateChain.Count > 0)
{
string immediate = row.TemplateChain[0];
if (!string.IsNullOrWhiteSpace(immediate))
{
templateUsage.TryGetValue(immediate, out int templateCount);
templateUsage[immediate] = templateCount + 1;
}
}
}
topTemplates = templateUsage
.OrderByDescending(entry => entry.Value)
.ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
.Take(TopTemplatesLimit)
.Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value))
.ToArray();
objectCategories = objectsByCategory
.OrderByDescending(entry => entry.Value)
.ThenBy(entry => entry.Key)
.Select(entry => new DashboardGalaxyCategoryCount(
entry.Key,
CategoryNamesById.TryGetValue(entry.Key, out string? name) ? name : $"Category {entry.Key}",
entry.Value))
.ToArray();
}
return new DashboardGalaxySummary(
Status: status,
LastQueriedAt: entry.LastQueriedAt,
LastSuccessAt: entry.LastSuccessAt,
LastDeployTime: entry.LastDeployTime,
LastError: entry.LastError,
ObjectCount: entry.ObjectCount,
AreaCount: entry.AreaCount,
AttributeCount: entry.AttributeCount,
HistorizedAttributeCount: entry.HistorizedAttributeCount,
AlarmAttributeCount: entry.AlarmAttributeCount,
TopTemplates: topTemplates,
ObjectCategories: objectCategories);
}
}
@@ -0,0 +1,47 @@
namespace 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)
{
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);
@@ -12,4 +12,5 @@ public sealed record DashboardSnapshot(
IReadOnlyList<DashboardWorkerSummary> Workers,
IReadOnlyList<DashboardMetricSummary> Metrics,
IReadOnlyList<DashboardFaultSummary> Faults,
EffectiveGatewayConfiguration Configuration);
EffectiveGatewayConfiguration Configuration,
DashboardGalaxySummary Galaxy);
@@ -1,6 +1,7 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Metrics;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
@@ -14,6 +15,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private readonly ISessionRegistry _sessionRegistry;
private readonly GatewayMetrics _metrics;
private readonly IGatewayConfigurationProvider _configurationProvider;
private readonly IGalaxyHierarchyCache _galaxyHierarchyCache;
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _gatewayStartedAt;
private readonly TimeSpan _snapshotInterval;
@@ -24,12 +26,14 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
ISessionRegistry sessionRegistry,
GatewayMetrics metrics,
IGatewayConfigurationProvider configurationProvider,
IGalaxyHierarchyCache galaxyHierarchyCache,
IOptions<GatewayOptions> options,
TimeProvider? timeProvider = 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));
ArgumentNullException.ThrowIfNull(options);
_timeProvider = timeProvider ?? TimeProvider.System;
@@ -65,7 +69,8 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
Workers: workerSummaries,
Metrics: CreateMetricSummaries(metricsSnapshot),
Faults: CreateFaultSummaries(sessions, generatedAt),
Configuration: _configurationProvider.GetEffectiveConfiguration());
Configuration: _configurationProvider.GetEffectiveConfiguration(),
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
}
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(