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 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;
|
||||
|
||||
Reference in New Issue
Block a user