feat(dashboard): host-side Galaxy summary projector over lib cache entry
This commit is contained in:
@@ -0,0 +1,113 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects a shared-library <see cref="GalaxyHierarchyCacheEntry"/> into the
|
||||||
|
/// dashboard's host-side <see cref="DashboardGalaxySummary"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The shared <c>ZB.MOM.WW.GalaxyRepository</c> cache entry does not compute a
|
||||||
|
/// dashboard summary; this projector relocates the summary logic that previously
|
||||||
|
/// lived inside the gateway's inline Galaxy hierarchy cache. It groups the entry's
|
||||||
|
/// objects into template usage and category counts, maps the cache status to the
|
||||||
|
/// dashboard status, and copies the timestamps and counts the entry already carries.
|
||||||
|
/// </remarks>
|
||||||
|
public static class DashboardGalaxySummaryProjector
|
||||||
|
{
|
||||||
|
private const int MaxTopTemplates = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="DashboardGalaxySummary"/> from a shared-library Galaxy
|
||||||
|
/// hierarchy cache entry.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entry">The shared-library cache entry to project.</param>
|
||||||
|
/// <returns>The dashboard summary derived from <paramref name="entry"/>.</returns>
|
||||||
|
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entry);
|
||||||
|
|
||||||
|
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
|
||||||
|
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
|
||||||
|
|
||||||
|
if (entry.Objects.Count == 0)
|
||||||
|
{
|
||||||
|
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
|
||||||
|
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Dictionary<int, int> objectsByCategory = new();
|
||||||
|
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (GalaxyObject obj in entry.Objects)
|
||||||
|
{
|
||||||
|
objectsByCategory.TryGetValue(obj.CategoryId, out int categoryCount);
|
||||||
|
objectsByCategory[obj.CategoryId] = categoryCount + 1;
|
||||||
|
|
||||||
|
if (obj.TemplateChain.Count > 0)
|
||||||
|
{
|
||||||
|
string immediate = obj.TemplateChain[0];
|
||||||
|
if (!string.IsNullOrWhiteSpace(immediate))
|
||||||
|
{
|
||||||
|
templateUsage.TryGetValue(immediate, out int templateCount);
|
||||||
|
templateUsage[immediate] = templateCount + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topTemplates = templateUsage
|
||||||
|
.OrderByDescending(usage => usage.Value)
|
||||||
|
.ThenBy(usage => usage.Key, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(MaxTopTemplates)
|
||||||
|
.Select(usage => new DashboardGalaxyTemplateUsage(usage.Key, usage.Value))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
objectCategories = objectsByCategory
|
||||||
|
.OrderByDescending(category => category.Value)
|
||||||
|
.ThenBy(category => category.Key)
|
||||||
|
.Select(category => new DashboardGalaxyCategoryCount(
|
||||||
|
category.Key,
|
||||||
|
ResolveCategoryName(category.Key),
|
||||||
|
category.Value))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DashboardGalaxySummary(
|
||||||
|
Status: MapDashboardStatus(entry.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardGalaxyStatus MapDashboardStatus(GalaxyCacheStatus status) => status switch
|
||||||
|
{
|
||||||
|
GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy,
|
||||||
|
GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale,
|
||||||
|
GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable,
|
||||||
|
_ => DashboardGalaxyStatus.Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ResolveCategoryName(int categoryId) => categoryId switch
|
||||||
|
{
|
||||||
|
1 => "WinPlatform",
|
||||||
|
3 => "AppEngine",
|
||||||
|
4 => "InTouchViewApp",
|
||||||
|
10 => "UserDefined",
|
||||||
|
11 => "FieldReference",
|
||||||
|
13 => "Area",
|
||||||
|
17 => "DIObject",
|
||||||
|
24 => "DDESuiteLinkClient",
|
||||||
|
26 => "OPCClient",
|
||||||
|
_ => $"Category {categoryId}",
|
||||||
|
};
|
||||||
|
}
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="DashboardGalaxySummaryProjector"/>, the host-side
|
||||||
|
/// projection that turns a shared-library <see cref="GalaxyHierarchyCacheEntry"/>
|
||||||
|
/// into the dashboard's <see cref="DashboardGalaxySummary"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DashboardGalaxySummaryProjectorTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that a healthy entry maps status, copies timestamps/counts, and
|
||||||
|
/// groups objects into template usage (ordered by instance count) and
|
||||||
|
/// category counts with resolved names.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Project_HealthyEntry_MapsStatusCopiesCountsAndGroupsObjects()
|
||||||
|
{
|
||||||
|
DateTimeOffset queriedAt = new(2026, 6, 25, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
DateTimeOffset successAt = new(2026, 6, 25, 10, 0, 1, TimeSpan.Zero);
|
||||||
|
DateTimeOffset deployAt = new(2026, 6, 24, 8, 30, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
// Two templates (PumpTemplate x2, ValveTemplate x1) and two categories
|
||||||
|
// (10 = UserDefined x3, 13 = Area x1).
|
||||||
|
GalaxyObject area = new()
|
||||||
|
{
|
||||||
|
GobjectId = 1,
|
||||||
|
BrowseName = "AreaA",
|
||||||
|
IsArea = true,
|
||||||
|
CategoryId = 13,
|
||||||
|
};
|
||||||
|
GalaxyObject pumpA = new()
|
||||||
|
{
|
||||||
|
GobjectId = 2,
|
||||||
|
BrowseName = "Pump01",
|
||||||
|
CategoryId = 10,
|
||||||
|
};
|
||||||
|
pumpA.TemplateChain.Add("$PumpTemplate");
|
||||||
|
GalaxyObject pumpB = new()
|
||||||
|
{
|
||||||
|
GobjectId = 3,
|
||||||
|
BrowseName = "Pump02",
|
||||||
|
CategoryId = 10,
|
||||||
|
};
|
||||||
|
pumpB.TemplateChain.Add("$PumpTemplate");
|
||||||
|
GalaxyObject valve = new()
|
||||||
|
{
|
||||||
|
GobjectId = 4,
|
||||||
|
BrowseName = "Valve01",
|
||||||
|
CategoryId = 10,
|
||||||
|
};
|
||||||
|
valve.TemplateChain.Add("$ValveTemplate");
|
||||||
|
|
||||||
|
GalaxyObject[] objects = [area, pumpA, pumpB, valve];
|
||||||
|
|
||||||
|
GalaxyHierarchyCacheEntry entry = new(
|
||||||
|
Status: GalaxyCacheStatus.Healthy,
|
||||||
|
Sequence: 7,
|
||||||
|
LastQueriedAt: queriedAt,
|
||||||
|
LastSuccessAt: successAt,
|
||||||
|
LastDeployTime: deployAt,
|
||||||
|
LastError: null,
|
||||||
|
Objects: objects,
|
||||||
|
Index: GalaxyHierarchyIndex.Build(objects),
|
||||||
|
ObjectCount: 4,
|
||||||
|
AreaCount: 1,
|
||||||
|
AttributeCount: 12,
|
||||||
|
HistorizedAttributeCount: 5,
|
||||||
|
AlarmAttributeCount: 2);
|
||||||
|
|
||||||
|
DashboardGalaxySummary summary = DashboardGalaxySummaryProjector.Project(entry);
|
||||||
|
|
||||||
|
Assert.Equal(DashboardGalaxyStatus.Healthy, summary.Status);
|
||||||
|
Assert.Equal(queriedAt, summary.LastQueriedAt);
|
||||||
|
Assert.Equal(successAt, summary.LastSuccessAt);
|
||||||
|
Assert.Equal(deployAt, summary.LastDeployTime);
|
||||||
|
Assert.Null(summary.LastError);
|
||||||
|
Assert.Equal(4, summary.ObjectCount);
|
||||||
|
Assert.Equal(1, summary.AreaCount);
|
||||||
|
Assert.Equal(12, summary.AttributeCount);
|
||||||
|
Assert.Equal(5, summary.HistorizedAttributeCount);
|
||||||
|
Assert.Equal(2, summary.AlarmAttributeCount);
|
||||||
|
|
||||||
|
// Templates ordered by instance count descending: PumpTemplate (2) before ValveTemplate (1).
|
||||||
|
Assert.Equal(2, summary.TopTemplates.Count);
|
||||||
|
Assert.Equal("$PumpTemplate", summary.TopTemplates[0].TemplateName);
|
||||||
|
Assert.Equal(2, summary.TopTemplates[0].InstanceCount);
|
||||||
|
Assert.Equal("$ValveTemplate", summary.TopTemplates[1].TemplateName);
|
||||||
|
Assert.Equal(1, summary.TopTemplates[1].InstanceCount);
|
||||||
|
|
||||||
|
// Categories ordered by object count descending: UserDefined (3) before Area (1).
|
||||||
|
Assert.Equal(2, summary.ObjectCategories.Count);
|
||||||
|
Assert.Equal(10, summary.ObjectCategories[0].CategoryId);
|
||||||
|
Assert.Equal("UserDefined", summary.ObjectCategories[0].CategoryName);
|
||||||
|
Assert.Equal(3, summary.ObjectCategories[0].ObjectCount);
|
||||||
|
Assert.Equal(13, summary.ObjectCategories[1].CategoryId);
|
||||||
|
Assert.Equal("Area", summary.ObjectCategories[1].CategoryName);
|
||||||
|
Assert.Equal(1, summary.ObjectCategories[1].ObjectCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that an unknown entry with no objects projects to the
|
||||||
|
/// <see cref="DashboardGalaxySummary.Unknown"/>-equivalent summary.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Project_UnknownEmptyEntry_ProducesUnknownEquivalentSummary()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty;
|
||||||
|
|
||||||
|
DashboardGalaxySummary summary = DashboardGalaxySummaryProjector.Project(entry);
|
||||||
|
|
||||||
|
Assert.Equal(DashboardGalaxyStatus.Unknown, summary.Status);
|
||||||
|
Assert.Null(summary.LastQueriedAt);
|
||||||
|
Assert.Null(summary.LastSuccessAt);
|
||||||
|
Assert.Null(summary.LastDeployTime);
|
||||||
|
Assert.Null(summary.LastError);
|
||||||
|
Assert.Equal(0, summary.ObjectCount);
|
||||||
|
Assert.Equal(0, summary.AreaCount);
|
||||||
|
Assert.Equal(0, summary.AttributeCount);
|
||||||
|
Assert.Equal(0, summary.HistorizedAttributeCount);
|
||||||
|
Assert.Equal(0, summary.AlarmAttributeCount);
|
||||||
|
Assert.Empty(summary.TopTemplates);
|
||||||
|
Assert.Empty(summary.ObjectCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the cache status enum is faithfully mapped to the dashboard
|
||||||
|
/// status enum across every defined value.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(GalaxyCacheStatus.Healthy, DashboardGalaxyStatus.Healthy)]
|
||||||
|
[InlineData(GalaxyCacheStatus.Stale, DashboardGalaxyStatus.Stale)]
|
||||||
|
[InlineData(GalaxyCacheStatus.Unavailable, DashboardGalaxyStatus.Unavailable)]
|
||||||
|
[InlineData(GalaxyCacheStatus.Unknown, DashboardGalaxyStatus.Unknown)]
|
||||||
|
public void Project_MapsCacheStatusToDashboardStatus(
|
||||||
|
GalaxyCacheStatus cacheStatus,
|
||||||
|
DashboardGalaxyStatus expected)
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with { Status = cacheStatus };
|
||||||
|
|
||||||
|
DashboardGalaxySummary summary = DashboardGalaxySummaryProjector.Project(entry);
|
||||||
|
|
||||||
|
Assert.Equal(expected, summary.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user