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
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:
26
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor
Normal file
26
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 — 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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Routes.razor
Normal file
39
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Routes.razor
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
@@ -2,6 +2,7 @@ using Akka.Hosting;
|
|||||||
using Serilog;
|
using Serilog;
|
||||||
using ZB.MOM.WW.OtOpcUa.AdminUI;
|
using ZB.MOM.WW.OtOpcUa.AdminUI;
|
||||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
|
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.AdminUI.Hubs;
|
||||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
|||||||
Reference in New Issue
Block a user