The Admin-003 fix gated every SignalR hub with [Authorize], but the server-side
Blazor HubConnection clients had no way to authenticate: the browser's HttpOnly
auth cookie is not reachable from the interactive circuit, so every hub negotiate
returned 401 and the Admin live-update feature was non-functional app-wide
(silently degraded on Hosts/ScriptLog, fatal on the cluster pages).
Introduce a token-based hub auth path:
- HubTokenService mints/validates short-lived tokens using ASP.NET Core Data
Protection (the same primitive that protects the auth cookie — no signing-key
management, no new packages). Tokens carry the user's name + roles.
- HubTokenAuthenticationHandler is a custom "HubToken" auth scheme that reads the
token from the Authorization: Bearer header (negotiate) or the access_token
query parameter (WebSocket upgrade).
- The "HubClients" authorization policy runs both the cookie and HubToken
schemes; the hub endpoints use RequireAuthorization("HubClients").
- AdminHubConnectionFactory builds hub connections with an AccessTokenProvider
that mints a fresh token for the circuit's authenticated user on every
(re)connect. All six hub-consuming pages now resolve connections through it.
Hub negotiate now returns 200 and the WebSocket upgrades (101); live updates
work. The best-effort try/catch guards added previously are kept as defence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
237 lines
7.8 KiB
Plaintext
237 lines
7.8 KiB
Plaintext
@page "/script-log"
|
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
|
@rendermode RenderMode.InteractiveServer
|
|
@using Microsoft.AspNetCore.Components.Web
|
|
@using Microsoft.AspNetCore.SignalR.Client
|
|
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
|
@inject NavigationManager Nav
|
|
@inject AdminHubConnectionFactory HubFactory
|
|
@implements IAsyncDisposable
|
|
|
|
<h1 class="page-title">Script log viewer</h1>
|
|
<p class="text-muted">
|
|
Live tail of the <code class="mono">scripts-*.log</code> file produced by the OPC UA Server's
|
|
Roslyn script runtime. Useful for diagnosing virtual-tag and scripted-alarm script errors in production.
|
|
Filter by script name to see only events from one script.
|
|
</p>
|
|
|
|
<div class="toolbar mb-3">
|
|
<input class="form-control form-control-sm"
|
|
style="max-width:22rem"
|
|
placeholder="Filter by script name (optional)"
|
|
@bind="_scriptNameFilter"
|
|
@bind:event="oninput"
|
|
disabled="@_streaming"/>
|
|
<select class="form-select form-select-sm ms-2" style="max-width:10rem" @bind="_minLevel" disabled="@_streaming">
|
|
<option value="VRB">All (VRB+)</option>
|
|
<option value="DBG">DBG+</option>
|
|
<option value="INF">INF+</option>
|
|
<option value="WRN">WRN+</option>
|
|
<option value="ERR">ERR+</option>
|
|
</select>
|
|
<button class="btn btn-sm btn-primary ms-2" @onclick="StartAsync" disabled="@_streaming">Start</button>
|
|
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="StopAsync" disabled="@(!_streaming)">Stop</button>
|
|
<button class="btn btn-sm btn-outline-danger ms-1" @onclick="ClearLines">Clear</button>
|
|
<span class="spacer"></span>
|
|
@if (_streaming)
|
|
{
|
|
<span class="chip chip-ok">Streaming</span>
|
|
}
|
|
else if (_stopped)
|
|
{
|
|
<span class="chip chip-idle">Stopped</span>
|
|
}
|
|
@if (_lines.Count > 0) { <span class="tb-count ms-2">@_lines.Count line@(_lines.Count == 1 ? "" : "s")</span> }
|
|
</div>
|
|
|
|
@if (_error is not null)
|
|
{
|
|
<section class="panel notice rise" style="animation-delay:.02s">
|
|
<span class="s-bad">@_error</span>
|
|
<button type="button" class="btn-close float-end" @onclick="() => _error = null"></button>
|
|
</section>
|
|
}
|
|
|
|
@if (_lines.Count == 0 && !_streaming && !_stopped)
|
|
{
|
|
<section class="panel notice rise" style="animation-delay:.04s">
|
|
Press <strong>Start</strong> to begin tailing the script log. The last @ScriptLogHub.TailSeedLines lines
|
|
are replayed first, then new lines appear as they are written by the OPC UA Server script runtime.
|
|
</section>
|
|
}
|
|
else if (_lines.Count == 0 && (_streaming || _stopped))
|
|
{
|
|
<section class="panel notice rise" style="animation-delay:.04s">
|
|
No matching log lines found. Check that the OPC UA Server is running and has executed at least one script,
|
|
and that the <code class="mono">ScriptLog:Directory</code> setting points to the correct log folder.
|
|
</section>
|
|
}
|
|
else
|
|
{
|
|
<section class="panel rise" style="animation-delay:.08s">
|
|
<div class="panel-head d-flex justify-content-between align-items-center">
|
|
<span>Script log</span>
|
|
<small class="text-muted">Latest @_lines.Count entries — oldest first</small>
|
|
</div>
|
|
<div class="table-wrap" style="max-height:60vh;overflow-y:auto" @ref="_tableContainer">
|
|
<table class="data-table" style="font-size:.85rem">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:7rem">Level</th>
|
|
<th style="width:14rem">Script</th>
|
|
<th>Message</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var line in _lines)
|
|
{
|
|
<tr class="@RowClass(line.Level)">
|
|
<td><span class="chip @LevelBadge(line.Level)">@line.Level</span></td>
|
|
<td><span class="mono small">@(line.ScriptName ?? "—")</span></td>
|
|
<td><span class="mono small" style="white-space:pre-wrap;word-break:break-all">@line.Raw</span></td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
@code {
|
|
// Keep at most this many lines in-memory to avoid DOM growth.
|
|
private const int MaxLines = 1000;
|
|
|
|
private HubConnection? _hub;
|
|
private CancellationTokenSource? _streamCts;
|
|
private List<ScriptLogLine> _lines = [];
|
|
private string _scriptNameFilter = string.Empty;
|
|
private string _minLevel = "INF";
|
|
private bool _streaming;
|
|
private bool _stopped;
|
|
private string? _error;
|
|
private ElementReference _tableContainer;
|
|
|
|
private static readonly string[] LevelOrder = ["VRB", "DBG", "INF", "WRN", "ERR", "FTL"];
|
|
|
|
private async Task StartAsync()
|
|
{
|
|
_error = null;
|
|
_streaming = false;
|
|
_stopped = false;
|
|
|
|
try
|
|
{
|
|
_hub ??= HubFactory.Create("/hubs/script-log");
|
|
|
|
if (_hub.State == HubConnectionState.Disconnected)
|
|
await _hub.StartAsync();
|
|
|
|
_streamCts = new CancellationTokenSource();
|
|
_streaming = true;
|
|
|
|
// Fire-and-forget into the background; updates come via StateHasChanged.
|
|
_ = Task.Run(() => ConsumeStreamAsync(_streamCts.Token));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_error = $"Failed to connect to script log hub: {ex.Message}";
|
|
_streaming = false;
|
|
}
|
|
}
|
|
|
|
private async Task ConsumeStreamAsync(CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var stream = _hub!.StreamAsync<ScriptLogLine>(
|
|
"TailLogAsync", _scriptNameFilter, ct);
|
|
|
|
await foreach (var line in stream.WithCancellation(ct))
|
|
{
|
|
if (!PassesLevelFilter(line.Level)) continue;
|
|
|
|
await InvokeAsync(() =>
|
|
{
|
|
_lines.Add(line);
|
|
if (_lines.Count > MaxLines)
|
|
_lines.RemoveRange(0, _lines.Count - MaxLines);
|
|
StateHasChanged();
|
|
});
|
|
}
|
|
}
|
|
catch (OperationCanceledException) { /* normal stop */ }
|
|
catch (Exception ex)
|
|
{
|
|
await InvokeAsync(() =>
|
|
{
|
|
_error = $"Stream error: {ex.Message}";
|
|
_streaming = false;
|
|
_stopped = true;
|
|
StateHasChanged();
|
|
});
|
|
return;
|
|
}
|
|
|
|
await InvokeAsync(() =>
|
|
{
|
|
_streaming = false;
|
|
_stopped = true;
|
|
StateHasChanged();
|
|
});
|
|
}
|
|
|
|
private async Task StopAsync()
|
|
{
|
|
if (_streamCts is not null)
|
|
{
|
|
await _streamCts.CancelAsync();
|
|
_streamCts.Dispose();
|
|
_streamCts = null;
|
|
}
|
|
_streaming = false;
|
|
_stopped = true;
|
|
}
|
|
|
|
private void ClearLines()
|
|
{
|
|
_lines.Clear();
|
|
_stopped = false;
|
|
}
|
|
|
|
private bool PassesLevelFilter(string level)
|
|
{
|
|
var minIdx = Array.IndexOf(LevelOrder, _minLevel);
|
|
var lineIdx = Array.IndexOf(LevelOrder, level);
|
|
return lineIdx >= minIdx;
|
|
}
|
|
|
|
private static string LevelBadge(string level) => level switch
|
|
{
|
|
"ERR" or "FTL" => "chip-bad",
|
|
"WRN" => "chip-warn",
|
|
"INF" => "chip-ok",
|
|
_ => "chip-idle",
|
|
};
|
|
|
|
private static string RowClass(string level) => level switch
|
|
{
|
|
"ERR" or "FTL" => "table-danger",
|
|
"WRN" => "table-warning",
|
|
_ => string.Empty,
|
|
};
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_streamCts is not null)
|
|
{
|
|
await _streamCts.CancelAsync();
|
|
_streamCts.Dispose();
|
|
}
|
|
if (_hub is not null)
|
|
{
|
|
await _hub.DisposeAsync();
|
|
_hub = null;
|
|
}
|
|
}
|
|
}
|