From 16cb078cd2753c6abed2a560daf6fcafbe72d837 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 11:45:53 -0400 Subject: [PATCH] feat(m9/T24b): move-data-connection UI dialog + action --- .../Pages/Design/DataConnections.razor | 48 +++++++ .../Shared/MoveDataConnectionDialog.razor | 105 ++++++++++++++ .../ServiceCollectionExtensions.cs | 8 ++ .../Services/DataConnectionMoveService.cs | 127 +++++++++++++++++ .../Services/IDataConnectionMoveService.cs | 55 ++++++++ .../DataConnectionsPageTests.cs | 54 +++++++ .../Shared/MoveDataConnectionDialogTests.cs | 133 ++++++++++++++++++ 7 files changed, 530 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/MoveDataConnectionDialog.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/DataConnectionMoveService.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IDataConnectionMoveService.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MoveDataConnectionDialogTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnections.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnections.razor index e0c41737..b5749142 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnections.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnections.razor @@ -42,6 +42,12 @@ + + @if (_loading) { @@ -124,6 +130,12 @@ Edit +
  • + +
  • + + + + + + + +} + +@code { + [Parameter] public bool IsVisible { get; set; } + [Parameter] public EventCallback IsVisibleChanged { get; set; } + [Parameter] public int ConnectionId { get; set; } + [Parameter] public string ConnectionName { get; set; } = string.Empty; + [Parameter] public IEnumerable<(int Id, string Label)> SiteOptions { get; set; } = Array.Empty<(int, string)>(); + + /// Raised after a successful move so the page can reload the tree. + [Parameter] public EventCallback OnMoved { get; set; } + + private bool _wasVisible; + private int? _targetSiteId; + private string? _error; + private bool _busy; + + protected override void OnParametersSet() + { + // Reset internal state on transition from hidden -> visible: default the + // picker to the first candidate site and clear any prior error. + if (IsVisible && !_wasVisible) + { + _targetSiteId = SiteOptions.Select(o => (int?)o.Id).FirstOrDefault(); + _error = null; + _busy = false; + } + _wasVisible = IsVisible; + } + + private async Task Close() + { + await IsVisibleChanged.InvokeAsync(false); + } + + private async Task Submit() + { + if (_targetSiteId is not int target || _busy) return; + + _busy = true; + _error = null; + var result = await MoveService.MoveAsync(ConnectionId, target); + _busy = false; + + if (result.Success) + { + await IsVisibleChanged.InvokeAsync(false); + await OnMoved.InvokeAsync(); + } + else + { + // Surface the server guard error inline; keep the dialog open. + _error = result.Error ?? "Move failed."; + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index c60cf40b..8ce19f6c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -116,6 +116,14 @@ public static class ServiceCollectionExtensions // ICentralHealthAggregator — no new plumbing. services.AddScoped(); + // Move-data-connection (M9-T24b): dispatches MoveDataConnectionCommand to the + // central ManagementActor through the in-process ManagementActorHolder seam — + // the same Ask path the HTTP /management endpoint uses. The server stays the + // single enforcer of the Designer gate and every move guard (target exists, no + // name collision, no instance binding, no native-alarm-source name reference); + // the move dialog only SUBMITS the command and renders the returned outcome. + 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/DataConnectionMoveService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/DataConnectionMoveService.cs new file mode 100644 index 00000000..3cfb96ca --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/DataConnectionMoveService.cs @@ -0,0 +1,127 @@ +using System.Security.Claims; +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 facade that +/// dispatches the to the central +/// ManagementActor through the in-process +/// (the same Ask seam the HTTP /management endpoint uses). The actor authorizes +/// the command against the supplied (Designer-gated) and +/// runs every move guard server-side — the target site must exist, the target must not +/// already own a same-named connection, no instance binding may reference the +/// connection, and no name-based native-alarm-source reference may be orphaned. None of +/// that is re-implemented here; a guard failure returns as a classified error. +/// +/// +/// Mirrors : the current Blazor principal is projected +/// to an so the server's role gate runs against the real +/// identity, and the three management response shapes plus any transport fault collapse +/// into a typed . +/// +public sealed class DataConnectionMoveService : IDataConnectionMoveService +{ + 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 DataConnectionMoveService( + 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 MoveAsync( + int connectionId, int targetSiteId, CancellationToken cancellationToken = default) + { + var response = await SendAsync( + new MoveDataConnectionCommand(connectionId, targetSiteId), cancellationToken); + return response switch + { + ManagementSuccess => DataConnectionMoveResult.Ok(), + ManagementUnauthorized unauthorized => DataConnectionMoveResult.Fail(unauthorized.Message), + ManagementError error => DataConnectionMoveResult.Fail(error.Error), + _ => DataConnectionMoveResult.Fail(DescribeFailure(response)), + }; + } + + /// + /// Wraps in a for the + /// current principal and Asks the ManagementActor. Transport faults (timeout, + /// actor not yet started, cancellation→propagated) become a synthetic + /// so callers handle 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 — username, + /// display name, role claims, and the permitted-site scope claims (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/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IDataConnectionMoveService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IDataConnectionMoveService.cs new file mode 100644 index 00000000..f5f97889 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IDataConnectionMoveService.cs @@ -0,0 +1,55 @@ +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Outcome of a single move-data-connection command dispatch. Wraps either success +/// or a human-readable guard error, so the move dialog can render an inline result +/// rather than reasoning about transport exceptions or the three management response +/// shapes. +/// +/// Whether the move succeeded. +/// A human-readable error message when is false; otherwise null. +public record DataConnectionMoveResult(bool Success, string? Error) +{ + /// Creates a successful result. + /// A successful . + public static DataConnectionMoveResult Ok() => new(true, null); + + /// Creates a failed result carrying . + /// The human-readable failure (typically a server guard message). + /// A failed . + public static DataConnectionMoveResult Fail(string error) => new(false, error); +} + +/// +/// CentralUI facade over the move-data-connection management command (M9-T24b). It +/// dispatches the strongly-typed MoveDataConnectionCommand to the central +/// ManagementActor through the in-process ManagementActorHolder seam — +/// the SAME Ask path the HTTP /management endpoint uses — so the server remains +/// the single enforcer of the Designer role gate and every move guard (target site +/// exists, no name collision at the target, no instance binding references it, no +/// name-based native-alarm-source references). The UI re-implements none of that: a +/// move is a command submitted to the server, and any guard failure comes back as a +/// classified error. +/// +/// +/// The current Blazor principal is projected to an AuthenticatedUser so the +/// server's role gate runs against the real identity. ManagementUnauthorized / +/// ManagementError / transport faults collapse into a typed +/// so the caller renders an inline outcome +/// rather than throwing. Mirrors ISecuredWriteService. +/// +public interface IDataConnectionMoveService +{ + /// + /// Moves the connection identified by to + /// via the guard-running ManagementActor. + /// + /// Primary key of the connection to move. + /// Primary key of the destination site. + /// Cancellation token. + /// A task that resolves to a — success, or a classified guard / transport failure. + Task MoveAsync( + int connectionId, + int targetSiteId, + CancellationToken cancellationToken = default); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs index 9d25ebd2..ab55eadd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs @@ -26,11 +26,17 @@ public class DataConnectionsPageTests : BunitContext // keeps every existing test green (no badge / unknown state when no report). private readonly IConnectionHealthQueryService _healthQuery = Substitute.For(); + // M9-T24b: the page now opens a Move-to-Site dialog that dispatches the move + // through IDataConnectionMoveService (the guard-running ManagementActor path). + // Substituted so the page renders without a real ManagementActor in scope. + private readonly IDataConnectionMoveService _moveService = + Substitute.For(); public DataConnectionsPageTests() { Services.AddSingleton(_siteRepo); Services.AddSingleton(_healthQuery); + Services.AddSingleton(_moveService); // Satisfy the page's [Inject] IDialogService — the host that actually // renders the dialog lives in MainLayout, not in bUnit's render scope. Services.AddScoped(); @@ -209,4 +215,52 @@ public class DataConnectionsPageTests : BunitContext Assert.Contains("bg-danger", disconnectedBadge.GetAttribute("class")); Assert.Contains("Disconnected", disconnectedBadge.TextContent); } + + [Fact] + public void ConnectionNode_Has_MoveToSiteAction() + { + // M9-T24b: each connection node exposes a "Move to Site…" action alongside + // Edit/Delete. + SeedRepos( + sites: new[] + { + new Site("Plant-A", "plant-a") { Id = 1 }, + new Site("Plant-B", "plant-b") { Id = 2 } + }, + connections: new[] { new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 } }); + + var cut = Render(); + FindToggleForLabel(cut, "Plant-A")!.Click(); + + Assert.Contains(cut.FindAll("button"), + b => b.TextContent.Contains("Move to Site")); + } + + [Fact] + public void MoveToSite_OpensDialog_ExcludingCurrentSite() + { + // M9-T24b: clicking "Move to Site…" opens the move dialog whose target-site + // picker omits the connection's current site (Plant-A) and lists the others. + SeedRepos( + sites: new[] + { + new Site("Plant-A", "plant-a") { Id = 1 }, + new Site("Plant-B", "plant-b") { Id = 2 }, + new Site("Plant-C", "plant-c") { Id = 3 } + }, + connections: new[] { new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 } }); + + var cut = Render(); + FindToggleForLabel(cut, "Plant-A")!.Click(); + + cut.FindAll("button").First(b => b.TextContent.Contains("Move to Site")).Click(); + + // The dialog renders with a site picker; the current site is excluded. + var dialog = cut.Find(".modal.show"); + var optionLabels = dialog.QuerySelectorAll("select option") + .Select(o => o.TextContent).ToList(); + Assert.Contains(optionLabels, l => l.Contains("Plant-B")); + Assert.Contains(optionLabels, l => l.Contains("Plant-C")); + Assert.DoesNotContain(optionLabels, l => l.Contains("Plant-A")); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MoveDataConnectionDialogTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MoveDataConnectionDialogTests.cs new file mode 100644 index 00000000..888643db --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MoveDataConnectionDialogTests.cs @@ -0,0 +1,133 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; + +/// +/// bUnit tests for the (M9-T24b). The dialog +/// surfaces a target-site picker (excluding the connection's current site) and, on +/// confirm, dispatches a MoveDataConnectionCommand through the management +/// path via — the SAME guard-running seam +/// the server uses. A guard error must be shown inline and the dialog must stay open; +/// a success must close the dialog and raise the refresh signal. +/// +/// The service is substituted so the tests capture the dispatched (connectionId, +/// targetSiteId) and simulate both a success and a guard-error response without a +/// real ManagementActor in scope. +/// +public class MoveDataConnectionDialogTests : BunitContext +{ + private readonly IDataConnectionMoveService _service = Substitute.For(); + + public MoveDataConnectionDialogTests() + { + Services.AddSingleton(_service); + } + + private IRenderedComponent RenderDialog( + int connectionId = 100, + string connectionName = "PLC-1", + bool visible = true, + IEnumerable<(int Id, string Label)>? siteOptions = null, + EventCallback? onMoved = null, + EventCallback? visibleChanged = null) + { + siteOptions ??= new[] { (2, "Plant-B"), (3, "Plant-C") }; + return Render(p => p + .Add(d => d.IsVisible, visible) + .Add(d => d.ConnectionId, connectionId) + .Add(d => d.ConnectionName, connectionName) + .Add(d => d.SiteOptions, siteOptions) + .Add(d => d.OnMoved, onMoved ?? default) + .Add(d => d.IsVisibleChanged, visibleChanged ?? default)); + } + + [Fact] + public void Visible_RendersTargetSitePicker_WithSuppliedOptions() + { + // (a) The dialog opens with a site picker whose options are exactly the + // supplied target sites (the page excludes the connection's current site). + var cut = RenderDialog(siteOptions: new[] { (2, "Plant-B"), (3, "Plant-C") }); + + var options = cut.FindAll("select option"); + Assert.Contains(options, o => o.TextContent.Contains("Plant-B")); + Assert.Contains(options, o => o.TextContent.Contains("Plant-C")); + // Picker reflects the connection name in the header. + Assert.Contains("PLC-1", cut.Markup); + } + + [Fact] + public void Hidden_RendersNothing() + { + var cut = RenderDialog(visible: false); + Assert.Empty(cut.Markup.Trim()); + } + + [Fact] + public void Confirm_DispatchesMoveCommand_WithConnectionAndTargetSiteIds() + { + // (b) Confirming dispatches the move through the management-dispatch service + // with the connection id + the selected target site id. + _service.MoveAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(DataConnectionMoveResult.Ok())); + + var cut = RenderDialog(connectionId: 100, siteOptions: new[] { (2, "Plant-B"), (3, "Plant-C") }); + + // Pick Plant-C (id 3) and confirm. + cut.Find("select").Change("3"); + cut.FindAll("button").First(b => b.TextContent.Contains("Move")).Click(); + + _service.Received(1).MoveAsync(100, 3, Arg.Any()); + } + + [Fact] + public void GuardError_IsShownInline_AndDialogStaysOpen() + { + // (c) A guard-error response is rendered inline and the dialog does NOT close + // (IsVisibleChanged is not raised with false; OnMoved is not raised). + const string guardError = + "Cannot move data connection 'PLC-1' (ID 100): it is referenced by 1 instance binding(s)."; + _service.MoveAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(DataConnectionMoveResult.Fail(guardError))); + + var closed = false; + var moved = false; + var cut = RenderDialog( + connectionId: 100, + siteOptions: new[] { (2, "Plant-B") }, + onMoved: EventCallback.Factory.Create(this, () => moved = true), + visibleChanged: EventCallback.Factory.Create(this, v => { if (!v) closed = true; })); + + cut.Find("select").Change("2"); + cut.FindAll("button").First(b => b.TextContent.Contains("Move")).Click(); + + Assert.Contains(guardError, cut.Markup); + Assert.False(closed, "Dialog must stay open on a guard error."); + Assert.False(moved, "OnMoved must not fire on a guard error."); + } + + [Fact] + public void Success_ClosesDialog_AndRaisesRefreshSignal() + { + _service.MoveAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(DataConnectionMoveResult.Ok())); + + var closed = false; + var moved = false; + var cut = RenderDialog( + connectionId: 100, + siteOptions: new[] { (2, "Plant-B") }, + onMoved: EventCallback.Factory.Create(this, () => moved = true), + visibleChanged: EventCallback.Factory.Create(this, v => { if (!v) closed = true; })); + + cut.Find("select").Change("2"); + cut.FindAll("button").First(b => b.TextContent.Contains("Move")).Click(); + + Assert.True(closed, "Dialog must close on success."); + Assert.True(moved, "OnMoved must fire on success to refresh the tree."); + } +}