Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor
T
Joseph Doherty 850d6774ea
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
feat(adminui): F15 Phase A — shell + auth + fleet + hosts pages
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.
2026-05-26 07:49:35 -04:00

197 lines
7.2 KiB
Plaintext

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