feat(adminui): F15 Phase A — shell + auth + fleet + hosts pages
Some checks failed
v2-ci / build (push) Failing after 38s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped

Implements Phase A of the F15 rebuild plan: minimum-viable Admin surface
with a working sign-in path and a fleet-state landing page. Decisions Q1–Q5
of docs/v2/AdminUI-rebuild-plan.md were taken as recommended.

- App.razor (moved into AdminUI library from the Host stub; vendored
  Bootstrap from RCL wwwroot — no public CDN, air-gap safe)
- Routes.razor (AuthorizeRouteView enforces page-level [Authorize])
- RedirectToLogin.razor (preserves returnUrl through the auth hop)
- Login.razor (static SSR, posts to /auth/login; Q5 wording about
  generic-vs-specific LDAP errors)
- Account.razor (identity + fleet roles + raw LDAP groups; Q4 — no
  per-cluster grants; fleet-wide LDAP-group → role mapping only)
- Fleet.razor (per-node deployment status: reads NodeDeploymentState
  + unions with IClusterRoleInfo.MembersWithRole("driver") so freshly-
  joined nodes appear as "waiting"; 10s auto-refresh)
- Hosts.razor (Akka cluster topology: members, status, roles, role-
  leader; 5s auto-refresh)

Host's stub App.razor deleted; Program.cs now points at
AdminUI.Components.App via an added using.

All 104 v2 tests remain green.
This commit is contained in:
Joseph Doherty
2026-05-26 07:49:35 -04:00
parent 5c754ecffd
commit 850d6774ea
9 changed files with 590 additions and 19 deletions

View File

@@ -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 <Routes/> 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). *@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>OtOpcUa Admin</title>
<base href="/"/>
<link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/css/bootstrap.min.css"/>
<link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/css/theme.css"/>
<link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/css/site.css"/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -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
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">My account</h4>
</div>
<AuthorizeView>
<Authorized>
@{
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();
}
<section class="card-grid rise" style="animation-delay:.02s">
<div class="metric-card">
<div class="panel-head">Identity</div>
<div class="kv"><span class="k">Username</span><span class="v mono">@username</span></div>
<div class="kv"><span class="k">Display name</span><span class="v">@displayName</span></div>
</div>
<div class="metric-card">
<div class="panel-head">Fleet roles</div>
<div class="kv">
<span class="k">Resolved roles</span>
<span class="v">
@if (roles.Count == 0)
{
<span class="text-muted">none — sign-in should have been blocked; session claim is likely stale</span>
}
else
{
@foreach (var r in roles)
{
<span class="chip chip-idle me-1">@r</span>
}
}
</span>
</div>
<div class="kv">
<span class="k">LDAP groups</span>
<span class="v">
@if (ldapGroups.Count == 0)
{
<span class="text-muted">none</span>
}
else
{
@foreach (var g in ldapGroups)
{
<span class="chip me-1 mono">@g</span>
}
}
</span>
</div>
</div>
</section>
<section class="panel notice rise" style="animation-delay:.08s">
Fleet roles come from LDAP group membership via the
<span class="mono">Authentication:Ldap:GroupToRole</span> 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.
</section>
</Authorized>
</AuthorizeView>

View File

@@ -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<OtOpcUaConfigDbContext> DbFactory
@implements IDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Fleet status</h4>
</div>
<div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
Refresh
</button>
<span class="text-muted small">
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
</span>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else if (_rows.Count == 0)
{
<section class="panel notice rise" style="animation-delay:.02s">
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.
</section>
}
else
{
<section class="agg-grid rise" style="animation-delay:.02s">
<div class="agg-card">
<div class="agg-label">Nodes</div>
<div class="agg-value numeric">@_rows.Count</div>
</div>
<div class="agg-card">
<div class="agg-label">Applied</div>
<div class="agg-value numeric">@_rows.Count(r => r.Status == NodeDeploymentStatus.Applied)</div>
</div>
<div class="agg-card caution">
<div class="agg-label">Applying</div>
<div class="agg-value numeric">@_rows.Count(r => r.Status == NodeDeploymentStatus.Applying)</div>
</div>
<div class="agg-card alert">
<div class="agg-label">Failed</div>
<div class="agg-value numeric">@_rows.Count(r => r.Status == NodeDeploymentStatus.Failed)</div>
</div>
</section>
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Nodes</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Node</th>
<th>Roles</th>
<th>Status</th>
<th>Applied at</th>
<th>Started at</th>
<th>Failure reason</th>
</tr>
</thead>
<tbody>
@foreach (var r in _rows)
{
<tr>
<td><span class="mono">@r.NodeId</span></td>
<td>
@foreach (var role in r.Roles)
{
<span class="chip chip-idle me-1">@role</span>
}
</td>
<td><span class="chip @StatusChipClass(r.Status)">@StatusLabel(r.Status)</span></td>
<td>@(r.AppliedAtUtc?.ToString("u") ?? "—")</td>
<td>@(r.StartedAtUtc?.ToString("u") ?? "—")</td>
<td>@(r.FailureReason ?? "")</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
private const int RefreshIntervalSeconds = 10;
private List<NodeRow>? _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<string>(),
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<string> Roles,
NodeDeploymentStatus? Status,
DateTime? StartedAtUtc,
DateTime? AppliedAtUtc,
string? FailureReason);
}

View File

@@ -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
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Cluster hosts</h4>
</div>
<div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
Refresh
</button>
<span class="text-muted small">
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
</span>
</div>
<section class="panel notice rise" style="animation-delay:.02s">
Each row is one Akka cluster member identified by <span class="mono">host:port</span>. Roles
drive which actors run on which node — <span class="mono">admin</span> nodes host the
control-plane singletons, <span class="mono">driver</span> nodes host the per-node runtime
actors. The leader columns identify which member currently owns each role's singletons.
</section>
@if (_rows is null)
{
<p>Loading…</p>
}
else if (_rows.Count == 0)
{
<section class="panel notice rise" style="animation-delay:.08s">
No cluster members visible. The local node may still be joining.
</section>
}
else
{
<section class="agg-grid rise" style="animation-delay:.08s">
<div class="agg-card">
<div class="agg-label">Members</div>
<div class="agg-value numeric">@_rows.Count</div>
</div>
<div class="agg-card">
<div class="agg-label">Up</div>
<div class="agg-value numeric">@_rows.Count(r => r.Status == "Up")</div>
</div>
<div class="agg-card caution">
<div class="agg-label">Joining/Leaving</div>
<div class="agg-value numeric">@_rows.Count(r => r.Status is "Joining" or "Leaving" or "Exiting")</div>
</div>
<div class="agg-card alert">
<div class="agg-label">Unreachable</div>
<div class="agg-value numeric">@_rows.Count(r => r.Unreachable)</div>
</div>
</section>
<section class="panel rise" style="animation-delay:.14s">
<div class="panel-head">Members</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Address</th>
<th>Status</th>
<th>Roles</th>
<th>Leader for</th>
</tr>
</thead>
<tbody>
@foreach (var r in _rows)
{
<tr>
<td>
<span class="mono">@r.Address</span>
@if (r.IsSelf) { <span class="chip chip-idle ms-2">self</span> }
</td>
<td>
<span class="chip @StatusChipClass(r.Status, r.Unreachable)">
@(r.Unreachable ? $"{r.Status} (unreachable)" : r.Status)
</span>
</td>
<td>
@foreach (var role in r.Roles)
{
<span class="chip chip-idle me-1">@role</span>
}
</td>
<td>
@if (r.LeaderRoles.Count == 0)
{
<span class="text-muted">—</span>
}
else
{
@foreach (var role in r.LeaderRoles)
{
<span class="chip chip-ok me-1">@role</span>
}
}
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
private const int RefreshIntervalSeconds = 5;
private List<MemberRow>? _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<string> Roles,
IReadOnlyCollection<string> LeaderRoles,
bool Unreachable,
bool IsSelf);
}

View File

@@ -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]
<div class="login-wrap rise" style="animation-delay:.02s">
<section class="panel">
<div class="panel-head">OtOpcUa Admin &mdash; sign in</div>
<div style="padding:1.1rem 1.1rem 1.25rem">
<form method="post" action="/auth/login" data-enhance="false">
@if (ReturnUrl is not null)
{
<input type="hidden" name="returnUrl" value="@ReturnUrl"/>
}
<div class="mb-3">
<label class="form-label" for="username">Username</label>
<input id="username" name="username" type="text"
class="form-control form-control-sm" autocomplete="username"/>
</div>
<div class="mb-3">
<label class="form-label" for="password">Password</label>
<input id="password" name="password" type="password"
class="form-control form-control-sm" autocomplete="current-password"/>
</div>
@if (!string.IsNullOrWhiteSpace(Error))
{
<div class="panel notice" style="margin-bottom:.85rem">@Error</div>
}
<button class="btn btn-primary w-100" type="submit">Sign in</button>
</form>
<div style="margin-top:1rem;padding-top:.85rem;border-top:1px solid var(--rule);
font-size:.78rem;color:var(--ink-faint)">
LDAP bind against the configured directory (per Q5 of the AdminUI rebuild plan:
generic error in production; specific reason when <span class="mono">Authentication:Ldap:AllowInsecureLdap=true</span>).
</div>
</div>
</section>
</div>
@code {
/// <summary>Error message surfaced by /auth/login after a failed bind.</summary>
[SupplyParameterFromQuery]
private string? Error { get; set; }
/// <summary>Original protected URL the operator was bounced from; round-tripped to the endpoint.</summary>
[SupplyParameterFromQuery]
private string? ReturnUrl { get; set; }
}

View File

@@ -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);
}
}

View File

@@ -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
<Router AppAssembly="@typeof(Routes).Assembly" AdditionalAssemblies="@AdditionalAssemblies">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@if (context.User.Identity?.IsAuthenticated != true)
{
<RedirectToLogin/>
}
else
{
<LayoutView Layout="@typeof(MainLayout)">
<p class="text-danger">You do not have permission to view this page.</p>
</LayoutView>
}
</NotAuthorized>
<Authorizing>
<LayoutView Layout="@typeof(MainLayout)"><p>Authorizing…</p></LayoutView>
</Authorizing>
</AuthorizeRouteView>
</Found>
</Router>
@code {
/// <summary>
/// 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.
/// </summary>
[Parameter]
public IEnumerable<System.Reflection.Assembly>? AdditionalAssemblies { get; set; }
}

View File

@@ -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. *@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -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;