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
+
+
+
+
+
+ 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
+{
+
+
+ @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")
+
+}
+
+@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]
+
+
+
+
OtOpcUa Admin — sign in
+
+
+
+
+ LDAP bind against the configured directory (per Q5 of the AdminUI rebuild plan:
+ generic error in production; specific reason when Authentication:Ldap:AllowInsecureLdap=true).
+
+
+
+
+
+@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;