973730d0eb
Admin-001: Routes.razor used a plain RouteView, so the page-level [Authorize] attributes on 11 pages were inert — every page, including mutating ones, was reachable fully unauthenticated. Admin-002: several pages (e.g. NewCluster, which writes config rows) carried no auth attribute at all. - Routes.razor: RouteView → AuthorizeRouteView with NotAuthorized / Authorizing slots; add RedirectToLogin component. - Program.cs: SetFallbackPolicy(RequireAuthenticatedUser) — secure by default for new pages/endpoints. - Login.razor: [AllowAnonymous] so login stays reachable; login page, /auth/* endpoints and static assets remain anonymous. - Add [Authorize] to the previously un-gated pages; NewCluster gated to the CanPublish (FleetAdmin) policy. Regression tests in PageAuthorizationTests pin that anonymous requests to protected/mutating routes are rejected and that login + static assets stay anonymously reachable. Admin test suite: 210/210 pass. Resolves code-review findings Admin-001 and Admin-002 (Critical). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
6.4 KiB
Plaintext
176 lines
6.4 KiB
Plaintext
@page "/fleet"
|
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
|
@using Microsoft.AspNetCore.Components.Web
|
|
@using Microsoft.EntityFrameworkCore
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
@rendermode RenderMode.InteractiveServer
|
|
@inject IServiceScopeFactory ScopeFactory
|
|
@implements IDisposable
|
|
|
|
<h1 class="page-title">Fleet status</h1>
|
|
|
|
<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" style="animation-delay:.02s">
|
|
No node state recorded yet. Nodes publish their state to the central DB on each poll; if
|
|
this list is empty, either no nodes have been registered or the poller hasn't run yet.
|
|
</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 == "Applied")</div>
|
|
</div>
|
|
<div class="agg-card caution">
|
|
<div class="agg-label">Stale</div>
|
|
<div class="agg-value numeric">@_rows.Count(r => IsStale(r))</div>
|
|
</div>
|
|
<div class="agg-card alert">
|
|
<div class="agg-label">Failed</div>
|
|
<div class="agg-value numeric">@_rows.Count(r => r.Status == "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>Cluster</th>
|
|
<th class="num">Generation</th>
|
|
<th>Status</th>
|
|
<th>Last applied</th>
|
|
<th>Last seen</th>
|
|
<th>Error</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var r in _rows)
|
|
{
|
|
<tr>
|
|
<td><span class="mono">@r.NodeId</span></td>
|
|
<td>@r.ClusterId</td>
|
|
<td class="num">@(r.GenerationId?.ToString() ?? "—")</td>
|
|
<td>
|
|
<span class="chip @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
|
|
</td>
|
|
<td>@FormatAge(r.AppliedAt)</td>
|
|
<td>@FormatAge(r.SeenAt)</td>
|
|
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
@code {
|
|
// Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
|
|
// the most recent published state without polling ahead of the broadcaster.
|
|
private const int RefreshIntervalSeconds = 5;
|
|
|
|
private List<FleetNodeRow>? _rows;
|
|
private bool _refreshing;
|
|
private DateTime? _lastRefreshUtc;
|
|
private Timer? _timer;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await RefreshAsync();
|
|
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
|
|
state: null,
|
|
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
|
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
|
}
|
|
|
|
private async Task RefreshAsync()
|
|
{
|
|
if (_refreshing) return;
|
|
_refreshing = true;
|
|
try
|
|
{
|
|
using var scope = ScopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
|
// Order on the join's plain columns before projecting into FleetNodeRow —
|
|
// EF Core cannot translate OrderBy over a property of a constructed record.
|
|
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
|
|
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new { s, n })
|
|
.OrderBy(x => x.n.ClusterId)
|
|
.ThenBy(x => x.s.NodeId)
|
|
.Select(x => new FleetNodeRow(
|
|
x.s.NodeId, x.n.ClusterId, x.s.CurrentGenerationId,
|
|
x.s.LastAppliedStatus != null ? x.s.LastAppliedStatus.ToString() : null,
|
|
x.s.LastAppliedError, x.s.LastAppliedAt, x.s.LastSeenAt))
|
|
.ToListAsync();
|
|
_rows = rows;
|
|
_lastRefreshUtc = DateTime.UtcNow;
|
|
}
|
|
finally
|
|
{
|
|
_refreshing = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private static bool IsStale(FleetNodeRow r)
|
|
{
|
|
if (r.SeenAt is null) return true;
|
|
return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
|
|
}
|
|
|
|
private static string RowClass(FleetNodeRow r) => r.Status switch
|
|
{
|
|
"Failed" => "table-danger",
|
|
_ when IsStale(r) => "table-warning",
|
|
_ => "",
|
|
};
|
|
|
|
private static string StatusBadge(string? status) => status switch
|
|
{
|
|
"Applied" => "chip-ok",
|
|
"Failed" => "chip-bad",
|
|
"Applying" => "chip-idle",
|
|
_ => "chip-idle",
|
|
};
|
|
|
|
private static string FormatAge(DateTime? t)
|
|
{
|
|
if (t is null) return "—";
|
|
var age = DateTime.UtcNow - t.Value;
|
|
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
|
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
|
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
|
return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
|
}
|
|
|
|
public void Dispose() => _timer?.Dispose();
|
|
|
|
internal sealed record FleetNodeRow(
|
|
string NodeId, string ClusterId, long? GenerationId,
|
|
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
|
|
}
|