diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor new file mode 100644 index 0000000..cb20c57 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor @@ -0,0 +1,26 @@ +@* Root Blazor component for the fused OtOpcUa.Host. Static-rendered shell; child components + opt into InteractiveServer on a per-component basis (the auth-related stays + non-interactive so cookie SignInAsync still runs while ASP.NET owns the HTTP response). + + Vendored Bootstrap 5 lives in this RCL's wwwroot/lib/bootstrap; the RCL static-asset + pipeline maps it under /_content/ZB.MOM.WW.OtOpcUa.AdminUI/... — no public-CDN dependency + so air-gapped fleet deployments keep working (Admin-010). *@ + + + + + + + OtOpcUa Admin + + + + + + + + + + + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Account.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Account.razor new file mode 100644 index 0000000..7bf82ea --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Account.razor @@ -0,0 +1,78 @@ +@page "/account" +@* v1's Account page surfaced per-cluster role grants alongside identity. v2 dropped per-cluster + grants in favour of fleet-wide LDAP-group → role mapping (Q4 of the AdminUI rebuild plan), so + this version only shows identity + the resolved fleet roles + raw LDAP groups for + troubleshooting. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@using System.Security.Claims + +
+

My account

+
+ + + + @{ + var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? context.User.Identity?.Name ?? "—"; + var displayName = context.User.Identity?.Name ?? "—"; + var roles = context.User.Claims + .Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(); + var ldapGroups = context.User.Claims + .Where(c => c.Type == "ldap_group").Select(c => c.Value) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(); + } + +
+
+
Identity
+
Username@username
+
Display name@displayName
+
+ +
+
Fleet roles
+
+ Resolved roles + + @if (roles.Count == 0) + { + none — sign-in should have been blocked; session claim is likely stale + } + else + { + @foreach (var r in roles) + { + @r + } + } + +
+
+ LDAP groups + + @if (ldapGroups.Count == 0) + { + none + } + else + { + @foreach (var g in ldapGroups) + { + @g + } + } + +
+
+
+ +
+ Fleet roles come from LDAP group membership via the + Authentication:Ldap:GroupToRole mapping. To change them, + edit the LDAP group on the directory server; the next sign-in picks up the change. + Sign out + sign back in to refresh the cookie claim. +
+
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor new file mode 100644 index 0000000..4f669ee --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor @@ -0,0 +1,180 @@ +@page "/fleet" +@* Per-node deployment status. v2 reads NodeDeploymentState (the per-(node, deployment) apply + progress row owned by each DriverHostActor) and projects the most-recent row per node. The + Akka cluster topology comes from IClusterRoleInfo so we can show nodes that haven't applied + anything yet alongside nodes that have. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IClusterRoleInfo Cluster +@inject IDbContextFactory DbFactory +@implements IDisposable + +
+

Fleet status

+
+ +
+ + + Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—") + +
+ +@if (_rows is null) +{ +

Loading…

+} +else if (_rows.Count == 0) +{ +
+ No driver-role nodes are currently Up in the Akka cluster, and no NodeDeploymentState + rows have been recorded yet. Either no driver nodes have joined or the cluster is still + forming. +
+} +else +{ +
+
+
Nodes
+
@_rows.Count
+
+
+
Applied
+
@_rows.Count(r => r.Status == NodeDeploymentStatus.Applied)
+
+
+
Applying
+
@_rows.Count(r => r.Status == NodeDeploymentStatus.Applying)
+
+
+
Failed
+
@_rows.Count(r => r.Status == NodeDeploymentStatus.Failed)
+
+
+ +
+
Nodes
+
+ + + + + + + + + + + + + @foreach (var r in _rows) + { + + + + + + + + + } + +
NodeRolesStatusApplied atStarted atFailure reason
@r.NodeId + @foreach (var role in r.Roles) + { + @role + } + @StatusLabel(r.Status)@(r.AppliedAtUtc?.ToString("u") ?? "—")@(r.StartedAtUtc?.ToString("u") ?? "—")@(r.FailureReason ?? "")
+
+
+} + +@code { + private const int RefreshIntervalSeconds = 10; + + private List? _rows; + private bool _refreshing; + private DateTime? _lastRefreshUtc; + private Timer? _timer; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + _timer = new Timer(_ => _ = InvokeAsync(LoadAsync), null, + TimeSpan.FromSeconds(RefreshIntervalSeconds), + TimeSpan.FromSeconds(RefreshIntervalSeconds)); + } + + private async Task RefreshAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + _refreshing = true; + StateHasChanged(); + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + // Project the most-recent NodeDeploymentState per node — that's the row the + // DriverHostActor most recently touched, regardless of which deployment it was for. + var states = await db.NodeDeploymentStates.AsNoTracking() + .GroupBy(s => s.NodeId) + .Select(g => g.OrderByDescending(s => s.StartedAtUtc).First()) + .ToListAsync(); + var byNode = states.ToDictionary(s => s.NodeId); + + // Union with current Akka driver members so a freshly-joined node that has no + // NodeDeploymentState row yet still appears as "waiting". + var akkaDrivers = Cluster.MembersWithRole("driver") + .Select(n => n.Value).ToHashSet(StringComparer.OrdinalIgnoreCase); + var allNodes = byNode.Keys.Union(akkaDrivers, StringComparer.OrdinalIgnoreCase) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(); + + _rows = allNodes.Select(nodeId => + { + byNode.TryGetValue(nodeId, out var state); + return new NodeRow( + NodeId: nodeId, + Roles: akkaDrivers.Contains(nodeId) ? new[] { "driver" } : Array.Empty(), + Status: state?.Status, + StartedAtUtc: state?.StartedAtUtc, + AppliedAtUtc: state?.AppliedAtUtc, + FailureReason: state?.FailureReason); + }).ToList(); + _lastRefreshUtc = DateTime.UtcNow; + } + finally + { + _refreshing = false; + StateHasChanged(); + } + } + + private static string StatusChipClass(NodeDeploymentStatus? status) => status switch + { + NodeDeploymentStatus.Applied => "chip-ok", + NodeDeploymentStatus.Applying => "chip-caution", + NodeDeploymentStatus.Failed => "chip-alert", + _ => "chip-idle", + }; + + private static string StatusLabel(NodeDeploymentStatus? status) => status?.ToString() ?? "waiting"; + + public void Dispose() => _timer?.Dispose(); + + private sealed record NodeRow( + string NodeId, + IReadOnlyCollection Roles, + NodeDeploymentStatus? Status, + DateTime? StartedAtUtc, + DateTime? AppliedAtUtc, + string? FailureReason); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor new file mode 100644 index 0000000..7f6ff26 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -0,0 +1,196 @@ +@page "/hosts" +@* Akka cluster topology: each member's NodeId (host:port), roles, leader status. v2 reshapes + v1's "driver host" page — there are no per-driver host rows yet (driver-instance child actors + land with F7). For now this is the cluster-membership view; expand to per-driver rows when + DriverHostActor starts spawning DriverInstanceActor children. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Akka.Actor +@using Akka.Cluster +@inject ActorSystem ActorSystem +@implements IDisposable + +
+

Cluster hosts

+
+ +
+ + + Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—") + +
+ +
+ Each row is one Akka cluster member identified by host:port. Roles + drive which actors run on which node — admin nodes host the + control-plane singletons, driver nodes host the per-node runtime + actors. The leader columns identify which member currently owns each role's singletons. +
+ +@if (_rows is null) +{ +

Loading…

+} +else if (_rows.Count == 0) +{ +
+ No cluster members visible. The local node may still be joining. +
+} +else +{ +
+
+
Members
+
@_rows.Count
+
+
+
Up
+
@_rows.Count(r => r.Status == "Up")
+
+
+
Joining/Leaving
+
@_rows.Count(r => r.Status is "Joining" or "Leaving" or "Exiting")
+
+
+
Unreachable
+
@_rows.Count(r => r.Unreachable)
+
+
+ +
+
Members
+
+ + + + + + + + + + + @foreach (var r in _rows) + { + + + + + + + } + +
AddressStatusRolesLeader for
+ @r.Address + @if (r.IsSelf) { self } + + + @(r.Unreachable ? $"{r.Status} (unreachable)" : r.Status) + + + @foreach (var role in r.Roles) + { + @role + } + + @if (r.LeaderRoles.Count == 0) + { + + } + else + { + @foreach (var role in r.LeaderRoles) + { + @role + } + } +
+
+
+} + +@code { + private const int RefreshIntervalSeconds = 5; + + private List? _rows; + private bool _refreshing; + private DateTime? _lastRefreshUtc; + private Timer? _timer; + + protected override void OnInitialized() + { + Refresh(); + _timer = new Timer(_ => InvokeAsync(() => { Refresh(); StateHasChanged(); }), null, + TimeSpan.FromSeconds(RefreshIntervalSeconds), + TimeSpan.FromSeconds(RefreshIntervalSeconds)); + } + + private async Task RefreshAsync() + { + _refreshing = true; + StateHasChanged(); + try + { + await Task.Yield(); + Refresh(); + } + finally + { + _refreshing = false; + StateHasChanged(); + } + } + + private void Refresh() + { + var cluster = Akka.Cluster.Cluster.Get(ActorSystem); + var state = cluster.State; + var unreachable = state.Unreachable + .Select(m => m.Address.ToString()).ToHashSet(); + var selfAddress = cluster.SelfAddress.ToString(); + + _rows = state.Members.Select(m => + { + var address = m.Address.ToString(); + var hostPort = $"{m.Address.Host ?? "?"}:{m.Address.Port ?? 0}"; + var leaderRoles = m.Roles + .Where(role => cluster.State.RoleLeader(role)?.ToString() == address) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase) + .ToList(); + return new MemberRow( + Address: hostPort, + Status: m.Status.ToString(), + Roles: m.Roles.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(), + LeaderRoles: leaderRoles, + Unreachable: unreachable.Contains(address), + IsSelf: address == selfAddress); + }) + .OrderBy(r => r.Address, StringComparer.OrdinalIgnoreCase) + .ToList(); + _lastRefreshUtc = DateTime.UtcNow; + } + + private static string StatusChipClass(string status, bool unreachable) => (status, unreachable) switch + { + (_, true) => "chip-alert", + ("Up", _) => "chip-ok", + ("Joining", _) or ("Leaving", _) or ("Exiting", _) or ("WeaklyUp", _) => "chip-caution", + ("Down", _) or ("Removed", _) => "chip-alert", + _ => "chip-idle", + }; + + public void Dispose() => _timer?.Dispose(); + + private sealed record MemberRow( + string Address, + string Status, + IReadOnlyCollection Roles, + IReadOnlyCollection LeaderRoles, + bool Unreachable, + bool IsSelf); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor new file mode 100644 index 0000000..af87ffc --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor @@ -0,0 +1,53 @@ +@page "/login" +@* Login MUST stay anonymously reachable — otherwise the fallback authorization policy + would lock operators out of the only way in (Admin-001). Static-rendered on purpose: + the form POSTs to /auth/login while ASP.NET still owns an unstarted HTTP response. + Calling SignInAsync from an interactive circuit would be too late. *@ +@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous] + + + +@code { + /// Error message surfaced by /auth/login after a failed bind. + [SupplyParameterFromQuery] + private string? Error { get; set; } + + /// Original protected URL the operator was bounced from; round-tripped to the endpoint. + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/RedirectToLogin.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/RedirectToLogin.razor new file mode 100644 index 0000000..dfc9f8e --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/RedirectToLogin.razor @@ -0,0 +1,17 @@ +@* Bounces an unauthenticated user to /login with the original URL preserved as + ?returnUrl=. The /auth/login endpoint reads the parameter and forwards after a + successful bind so deep-links survive the auth hop. *@ + +@inject NavigationManager Nav + +@code { + protected override void OnInitialized() + { + var current = Nav.ToBaseRelativePath(Nav.Uri); + var returnUrl = string.IsNullOrEmpty(current) || current.StartsWith("login", StringComparison.OrdinalIgnoreCase) + ? null + : "/" + current; + var target = returnUrl is null ? "/login" : $"/login?returnUrl={Uri.EscapeDataString(returnUrl)}"; + Nav.NavigateTo(target, forceLoad: true); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Routes.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Routes.razor new file mode 100644 index 0000000..cc1b56b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Routes.razor @@ -0,0 +1,39 @@ +@* Router with AuthorizeRouteView so page-level [Authorize] attributes are enforced + (with plain RouteView, the attribute is inert — Admin-001). Unauthenticated users + hit the NotAuthorized slot and are bounced to /login; the route they came from is + round-tripped as ?returnUrl=. *@ + +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout + + + + + + @if (context.User.Identity?.IsAuthenticated != true) + { + + } + else + { + +

You do not have permission to view this page.

+
+ } +
+ +

Authorizing…

+
+
+
+
+ +@code { + /// + /// Hosts that want to expose pages defined in their own assembly pass them here. The fused + /// Host doesn't currently host its own routable pages — everything lives in this RCL — but + /// the parameter is here so a downstream consumer (or test rig) can extend without forking + /// Routes.razor. + /// + [Parameter] + public IEnumerable? AdditionalAssemblies { get; set; } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/App.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Host/App.razor deleted file mode 100644 index 9beb28c..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/App.razor +++ /dev/null @@ -1,19 +0,0 @@ -@* Root Blazor component for the fused OtOpcUa.Host. Pulls in the AdminUI library's - _Imports + the Deployments page. The full layout (sidebar, top bar, etc.) is part of - the legacy Admin migration tracked as F15 — for now this is the bare minimum that lets - the Razor pipeline render. *@ - - - - - - - - - - - - - - - diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 69147d1..88c65ed 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -2,6 +2,7 @@ using Akka.Hosting; using Serilog; using ZB.MOM.WW.OtOpcUa.AdminUI; using ZB.MOM.WW.OtOpcUa.AdminUI.Clients; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components; using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Configuration;