diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor index 24cea2d7..78a8e78a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -14,6 +14,7 @@ @inject ICentralUiRepository CentralUiRepository @inject TemplateService TemplateService @inject ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.ScriptAnalysisService AnalysisService +@inject ZB.MOM.WW.ScadaBridge.CentralUI.Services.ITemplateInheritanceQueryService InheritanceQuery @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager NavigationManager @inject IDialogService Dialog @@ -62,6 +63,12 @@ private Template? _ownerTemplate; private TemplateComposition? _ownerComposition; + // M9-T26b: the FULL transitively-resolved inherited member set (multi-level + // chain + post-creation base additions) + staleness, fetched read-only via + // GetResolvedTemplateMembersCommand. Populated only for derived templates; + // null when the read failed (editor falls back to the stored-row view). + private Commons.Messages.Management.ResolvedTemplateMembers? _resolved; + private bool _loading = true; private string? _loadError; private string _activeTab = "attributes"; @@ -195,6 +202,7 @@ _baseTemplate = null; _ownerTemplate = null; _ownerComposition = null; + _resolved = null; if (_selectedTemplate.IsDerived && _selectedTemplate.ParentTemplateId.HasValue) { _baseTemplate = await TemplateEngineRepository.GetTemplateByIdAsync(_selectedTemplate.ParentTemplateId.Value); @@ -218,6 +226,14 @@ } } } + + // M9-T26b: resolve the FULL inherited member set (whole chain + + // post-creation base additions) read-only. The stored-row tables + // above only carry the IMMEDIATE base; this surfaces the rest with + // origin annotation + a base-changed staleness banner. Read-only: + // a failed read leaves _resolved null and the editor degrades to + // the stored-row view. + _resolved = await InheritanceQuery.ResolveAsync(Id); } // Editor metadata: child compositions + every parent that @@ -270,6 +286,32 @@ } + + @* M9-T26b: read-only base-changed banner. Informational only — surfaced + when the freshly-resolved inherited set differs from this template's + stored copy (a multi-level inherited member, or a base member added + after this template was created). No action button: a deploy already + resolves fresh, so this is purely an authoring heads-up. *@ + @if (_resolved?.Staleness.IsStale == true) + { +
+ + Base template changed — + @_resolved.Staleness.DifferingMemberCount inherited member(s) differ from this template's stored copy. + The effective set below reflects the live base; a deploy resolves fresh. +
+ } + + @* M9-T26b: the FULL transitively-resolved inherited member set — the whole + chain (grandparent + further ancestors) plus base members added after + this template was created, which the immediate-base tables below cannot + show. Read-only preview; each row carries its origin template + lock + state, and alarms surface the merged effective trigger configuration. *@ + @if (_resolved != null) + { + @RenderInheritedSet() + } +

@_selectedTemplate.Name

@@ -419,6 +461,115 @@ } }; + // ---- M9-T26b: full resolved inherited set (read-only preview) ---- + + /// + /// Renders the FULL transitively-resolved effective member set (attributes, + /// alarms, scripts, native alarm sources) for a derived template. Read-only: + /// no edit / override / deploy actions — purely an authoring view that shows + /// what the whole inheritance chain resolves to (multi-level + post-creation + /// base additions), which the immediate-base tables below cannot surface. + /// + private RenderFragment RenderInheritedSet() => __builder => + { + var r = _resolved!; +
+
+ + Effective inherited set + + Resolved across the full inheritance chain (read-only). Edits stay on the per-tab tables below. + +
+
+ @RenderInheritedMemberTable("Attributes", r.Attributes, showTrigger: false) + @RenderInheritedMemberTable("Alarms", r.Alarms, showTrigger: true) + @RenderInheritedMemberTable("Scripts", r.Scripts, showTrigger: false) + @RenderInheritedMemberTable("Native Alarm Sources", r.NativeAlarmSources, showTrigger: false) + + @if (r.Attributes.Count == 0 && r.Alarms.Count == 0 + && r.Scripts.Count == 0 && r.NativeAlarmSources.Count == 0) + { +

No resolved members.

+ } +
+
+ }; + + /// + /// Renders one section (attributes / alarms / scripts / native sources) of the + /// resolved inherited set. Each row shows the effective value (for alarms, the + /// merged EffectiveTriggerConfiguration when ), + /// the origin template, and inherited / locked / base-locked annotations. + /// + private RenderFragment RenderInheritedMemberTable( + string title, + IReadOnlyList members, + bool showTrigger) => __builder => + { + @if (members.Count > 0) + { +
@title @members.Count
+ + + + + + @if (showTrigger) + { + + } + + + + + + @foreach (var m in members) + { + + + + @if (showTrigger) + { + + } + + + + } + +
NameEffective valueTrigger config (effective)SourceLock
@m.Name@(m.EffectiveValue ?? "—") + @(string.IsNullOrEmpty(m.EffectiveTriggerConfiguration) ? "—" : m.EffectiveTriggerConfiguration) + + @if (m.IsInherited) + { + + Inherited from @m.OriginTemplateName + + } + else + { + Local + } + + @if (m.IsBaseLocked) + { + 🔒 Base-locked + } + else if (m.IsLocked) + { + Locked + } + else + { + Unlocked + } +
+ } + }; + private async Task DeleteTemplate() { if (_selectedTemplate == null) return; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index bc796c60..0f0de4da 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -141,6 +141,13 @@ public static class ServiceCollectionExtensions // DbContext). Read-only; no mutation goes through it. services.AddScoped(); + // Template inheritance preview (M9-T26b): a read-only facade that dispatches + // GetResolvedTemplateMembersCommand to the central ManagementActor through the + // in-process ManagementActorHolder seam (same Ask path as the HTTP /management + // endpoint). Powers the template editor's FULL transitively-inherited member set + // + base-changed staleness banner; never mutates rows, not on the deploy path. + services.AddScoped(); + // Roslyn-backed C# analysis for the Monaco script editor. // Scoped because SharedScriptCatalog wraps a scoped service. services.AddMemoryCache(o => o.SizeLimit = 200); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ITemplateInheritanceQueryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ITemplateInheritanceQueryService.cs new file mode 100644 index 00000000..626b51a6 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ITemplateInheritanceQueryService.cs @@ -0,0 +1,35 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Read-only CentralUI facade over the template-inheritance resolve query +/// (M9-T26b). Dispatches the to +/// the central ManagementActor through the in-process +/// ManagementActorHolder seam — the SAME Ask path the HTTP +/// /management endpoint uses — and returns the freshly-resolved +/// (the full transitively-inherited member +/// set + staleness summary) so the template editor can render the effective +/// inherited view and a base-changed banner. +/// +/// +/// This is a pure authoring-preview read: it never mutates stored rows and is +/// not on the deploy path. A read failure (transport fault, actor not started) +/// yields null (logged) so the editor degrades to its stored-row view +/// rather than throwing. Mirrors 's read +/// path / the other ManagementActorHolder-backed query facades. +/// +public interface ITemplateInheritanceQueryService +{ + /// + /// Resolves the effective inherited member set for a template. + /// + /// The template to resolve. + /// Cancellation token. + /// + /// The freshly-resolved , or null + /// when the read failed (the editor then falls back to its stored-row view). + /// + Task ResolveAsync( + int templateId, CancellationToken cancellationToken = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/TemplateInheritanceQueryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/TemplateInheritanceQueryService.cs new file mode 100644 index 00000000..4bee5a99 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/TemplateInheritanceQueryService.cs @@ -0,0 +1,133 @@ +using System.Security.Claims; +using System.Text.Json; +using Akka.Actor; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.ManagementService; +using ZB.MOM.WW.ScadaBridge.Security; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Default implementation — a thin +/// read-only facade that dispatches the +/// to the central ManagementActor through the in-process +/// (the same Ask seam the HTTP /management +/// endpoint uses). The actor walks the full inheritance chain and returns the +/// effective member set + staleness; none of that is re-implemented here. Mirrors +/// 's read path. +/// +public sealed class TemplateInheritanceQueryService : ITemplateInheritanceQueryService +{ + /// + /// camelCase + case-insensitive, matching ManagementActor.SerializeResult's + /// options. is produced with those settings, + /// so the deserializer must mirror them to bind every property. + /// + private static readonly JsonSerializerOptions ResultDeserializerOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private static readonly TimeSpan AskTimeout = TimeSpan.FromSeconds(30); + + private readonly ManagementActorHolder _holder; + private readonly AuthenticationStateProvider _auth; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the . + /// + /// Holder for the central ManagementActor reference. + /// Authentication state provider used to project the current principal. + /// Logger instance. + public TemplateInheritanceQueryService( + ManagementActorHolder holder, + AuthenticationStateProvider auth, + ILogger logger) + { + _holder = holder ?? throw new ArgumentNullException(nameof(holder)); + _auth = auth ?? throw new ArgumentNullException(nameof(auth)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task ResolveAsync( + int templateId, CancellationToken cancellationToken = default) + { + var response = await SendAsync(new GetResolvedTemplateMembersCommand(templateId), cancellationToken); + if (response is ManagementSuccess success) + { + return JsonSerializer.Deserialize( + success.JsonData, ResultDeserializerOptions); + } + + // Read path: log + return null so the editor falls back to its stored-row view. + _logger.LogWarning( + "GetResolvedTemplateMembers failed for template {TemplateId}: {Response}", + templateId, DescribeFailure(response)); + return null; + } + + /// + /// Wraps in a for the + /// current principal and Asks the ManagementActor. Transport faults (timeout, + /// actor not yet started) become a synthetic so the + /// caller handles one response shape. + /// + private async Task SendAsync(object command, CancellationToken cancellationToken) + { + var actor = _holder.ActorRef; + if (actor is null) + { + return new ManagementError( + string.Empty, "Management service is not ready.", "SERVICE_UNAVAILABLE"); + } + + var user = await BuildAuthenticatedUserAsync(); + var envelope = new ManagementEnvelope(user, command, Guid.NewGuid().ToString("N")); + + try + { + return await actor.Ask(envelope, AskTimeout, cancellationToken); + } + catch (OperationCanceledException) + { + // Caller-initiated cancel (e.g. circuit teardown) — propagate cleanly. + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "ManagementActor Ask failed for {Command}", command.GetType().Name); + return new ManagementError(string.Empty, ex.Message, "TRANSPORT_ERROR"); + } + } + + /// + /// Projects the current Blazor to the + /// the actor authorizes against (mirrors the + /// claim set the HTTP endpoint constructs). + /// + private async Task BuildAuthenticatedUserAsync() + { + var state = await _auth.GetAuthenticationStateAsync(); + var principal = state.User; + + var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown"; + var displayName = principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value ?? username; + var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToArray(); + var permittedSiteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToArray(); + + return new AuthenticatedUser(username, displayName, roles, permittedSiteIds); + } + + /// Renders a fallback description for an unexpected/failure response. + private static string DescribeFailure(object response) => response switch + { + ManagementUnauthorized unauthorized => unauthorized.Message, + ManagementError error => error.Error, + _ => "Unexpected response from the management service.", + }; +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateEditInheritedSetTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateEditInheritedSetTests.cs new file mode 100644 index 00000000..bfb555c1 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateEditInheritedSetTests.cs @@ -0,0 +1,253 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Security; +using ZB.MOM.WW.ScadaBridge.TemplateEngine; +using TemplateEditPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TemplateEdit; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; + +/// +/// bUnit tests for the template editor's READ-ONLY inheritance preview (M9-T26b): +/// rendering the FULL transitively-resolved inherited member set (not just the +/// immediate base) annotated with origin, plus a read-only base-changed staleness +/// banner. The resolve query is dispatched through +/// , which is substituted here so the +/// tests feed a crafted without a real +/// ManagementActor. No mutation / deploy-path behavior is exercised. +/// +public class TemplateEditInheritedSetTests : BunitContext +{ + private readonly ITemplateEngineRepository _repo = Substitute.For(); + private readonly ICentralUiRepository _centralUi = Substitute.For(); + private readonly IAuditService _audit = Substitute.For(); + private readonly ISharedScriptCatalog _sharedScripts = Substitute.For(); + private readonly ITemplateInheritanceQueryService _resolve = + Substitute.For(); + + // The derived template under edit (id 30) and its immediate base (id 20). + private const int DerivedId = 30; + private const int BaseId = 20; + + public TemplateEditInheritedSetTests() + { + Services.AddSingleton(_repo); + Services.AddSingleton(_centralUi); + Services.AddSingleton(_audit); + Services.AddSingleton(_resolve); + Services.AddScoped(); + Services.AddScoped(); + + // ScriptAnalysisService is injected but never exercised on the initial + // render — supply its collaborators so DI can construct it. + Services.AddSingleton(_sharedScripts); + Services.AddSingleton(new MemoryCache(new MemoryCacheOptions())); + Services.AddScoped(); + + AddTestAuth(); + SeedDerivedTemplateRows(); + } + + private void AddTestAuth() + { + var claims = new[] + { + new Claim(JwtTokenService.UsernameClaimType, "tester"), + new Claim(JwtTokenService.RoleClaimType, "Designer"), + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + } + + /// + /// Seeds the stored-row repository view of a derived template. The stored rows + /// deliberately omit the transitively-inherited members — those only appear via + /// the resolved set the query service returns. + /// + private void SeedDerivedTemplateRows() + { + var grandparent = new Template("Root") { Id = 10 }; + var baseTemplate = new Template("Base") { Id = BaseId, ParentTemplateId = 10 }; + var derived = new Template("Derived") + { + Id = DerivedId, + ParentTemplateId = BaseId, + IsDerived = true, + }; + + _repo.GetAllTemplatesAsync(Arg.Any()) + .Returns(Task.FromResult>( + new List