Live tail of the scripts-*.log 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.
@if (_error is not null)
{
@_error
}
@if (_lines.Count == 0 && !_streaming && !_stopped)
{
Press Start 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.
}
else if (_lines.Count == 0 && (_streaming || _stopped))
{
No matching log lines found. Check that the OPC UA Server is running and has executed at least one script,
and that the ScriptLog:Directory setting points to the correct log folder.
}
else
{
Script log
Latest @_lines.Count entries — oldest first
Level
Script
Message
@foreach (var line in _lines)
{
@line.Level
@(line.ScriptName ?? "—")
@line.Raw
}
}
@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 _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(
"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;
}
}
}