From 8678b6cb87bb995ac6ede2e19f380f55e6fadccf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 11:10:55 -0400 Subject: [PATCH] feat(dashboard): host-side Galaxy summary projector over lib cache entry --- .../DashboardGalaxySummaryProjector.cs | 113 +++++++++++++ .../DashboardGalaxySummaryProjectorTests.cs | 148 ++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummaryProjector.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGalaxySummaryProjectorTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummaryProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummaryProjector.cs new file mode 100644 index 0000000..3815d58 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGalaxySummaryProjector.cs @@ -0,0 +1,113 @@ +using ZB.MOM.WW.GalaxyRepository; +using ZB.MOM.WW.GalaxyRepository.Grpc; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// Projects a shared-library into the +/// dashboard's host-side . +/// +/// +/// The shared ZB.MOM.WW.GalaxyRepository 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. +/// +public static class DashboardGalaxySummaryProjector +{ + private const int MaxTopTemplates = 10; + + /// + /// Builds a from a shared-library Galaxy + /// hierarchy cache entry. + /// + /// The shared-library cache entry to project. + /// The dashboard summary derived from . + public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + IReadOnlyList topTemplates; + IReadOnlyList objectCategories; + + if (entry.Objects.Count == 0) + { + topTemplates = Array.Empty(); + objectCategories = Array.Empty(); + } + else + { + Dictionary objectsByCategory = new(); + Dictionary 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}", + }; +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGalaxySummaryProjectorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGalaxySummaryProjectorTests.cs new file mode 100644 index 0000000..7ce303c --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGalaxySummaryProjectorTests.cs @@ -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; + +/// +/// Unit tests for , the host-side +/// projection that turns a shared-library +/// into the dashboard's . +/// +public sealed class DashboardGalaxySummaryProjectorTests +{ + /// + /// 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. + /// + [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); + } + + /// + /// Verifies that an unknown entry with no objects projects to the + /// -equivalent 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); + } + + /// + /// Verifies that the cache status enum is faithfully mapped to the dashboard + /// status enum across every defined value. + /// + [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); + } +}