Gap 2 (#25): VirtualTagsTab.razor + /virtual-tags global page — list/create/toggle virtual tags per draft generation with DataType, Script, trigger, Historize, Enabled fields. Tab wired into DraftEditor. Gap 3 (#26): ScriptedAlarmsTab.razor + /scripted-alarms global page — list/create scripted alarms with AlarmType, Severity, MessageTemplate, PredicateScript, HistorizeToAveva, Retain. SeverityBand helper shows Low/Medium/High/Critical label. Tab wired into DraftEditor. Gap 4 (#27): ScriptLogHub (SignalR IAsyncEnumerable stream) tails scripts-*.log with optional ScriptName filter; ScriptLog.razor provides Start/Stop/Clear controls plus level filter dropdown. Hub registered at /hubs/script-log in Program.cs. Nav rail gains a "Scripting" eyebrow with entries for all three pages. 19 new unit tests for ScriptLogHub parse/filter/tail helpers (Category=Unit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Streams lines from the server's <c>scripts-*.log</c> file(s) to the Admin UI
|
||||
/// (Phase 7 Stream F.5). Clients call <see cref="TailLogAsync"/> with an optional
|
||||
/// <paramref name="scriptNameFilter"/> to see only events from a named script.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Log files are looked up at <c>ScriptLog:Directory</c> (appsettings.json) relative
|
||||
/// to the current working directory, defaulting to <c>logs</c>. The glob pattern
|
||||
/// <c>scripts-*.log</c> is applied and the most-recently-written file is tailed.
|
||||
/// If no matching file is found an empty stream is returned — the UI shows a notice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each streamed <see cref="ScriptLogLine"/> carries the raw text, an extracted level
|
||||
/// (parsed from the Serilog compact format <c>[INF]</c> / <c>[WRN]</c> / <c>[ERR]</c>),
|
||||
/// and the extracted <c>ScriptName</c> property value when present.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tail semantics: up to <see cref="TailSeedLines"/> of the existing file are replayed
|
||||
/// first, then new lines are emitted as they are appended. The stream is cancelled when
|
||||
/// the client disconnects.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptLogHub(IConfiguration configuration, ILogger<ScriptLogHub> logger) : Hub
|
||||
{
|
||||
/// <summary>Number of existing lines to replay from the end of the file before live-tailing.</summary>
|
||||
public const int TailSeedLines = 200;
|
||||
|
||||
/// <summary>Poll cadence for new lines while the log is not being actively appended.</summary>
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// SignalR server-to-client stream. The caller awaits <c>await foreach</c> on the returned
|
||||
/// <see cref="IAsyncEnumerable{T}"/> via the hub-stream protocol. Cancelled automatically
|
||||
/// when the client disconnects or the provided <paramref name="ct"/> fires.
|
||||
/// </summary>
|
||||
/// <param name="scriptNameFilter">
|
||||
/// Optional script name. When non-empty only lines whose <c>ScriptName</c> property
|
||||
/// matches (case-insensitive contains) are emitted.
|
||||
/// </param>
|
||||
/// <param name="ct">Hub-provided cancellation token, cancelled on disconnect.</param>
|
||||
public async IAsyncEnumerable<ScriptLogLine> TailLogAsync(
|
||||
string? scriptNameFilter,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
var logDir = configuration["ScriptLog:Directory"] ?? "logs";
|
||||
var pattern = "scripts-*.log";
|
||||
|
||||
// Find the most recently written matching file.
|
||||
string? logFile;
|
||||
try
|
||||
{
|
||||
logFile = Directory.GetFiles(logDir, pattern, SearchOption.TopDirectoryOnly)
|
||||
.OrderByDescending(File.GetLastWriteTimeUtc)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
logger.LogDebug("Script log directory '{Dir}' not found — yielding empty stream", logDir);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (logFile is null)
|
||||
{
|
||||
logger.LogDebug("No files matching '{Pattern}' in '{Dir}' — yielding empty stream", pattern, logDir);
|
||||
yield break;
|
||||
}
|
||||
|
||||
logger.LogDebug("Tailing script log '{File}' filter='{Filter}'", logFile, scriptNameFilter);
|
||||
|
||||
// Replay seed lines from the end of the current file, then tail.
|
||||
long seekPosition;
|
||||
var seedLines = ReadTailLines(logFile, TailSeedLines, out seekPosition);
|
||||
|
||||
foreach (var line in seedLines)
|
||||
{
|
||||
if (ct.IsCancellationRequested) yield break;
|
||||
var parsed = ParseLine(line);
|
||||
if (Matches(parsed, scriptNameFilter))
|
||||
yield return parsed;
|
||||
}
|
||||
|
||||
// Live-tail: poll for new bytes appended after seekPosition.
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(PollInterval, ct); }
|
||||
catch (OperationCanceledException) { yield break; }
|
||||
|
||||
IReadOnlyList<string> newLines;
|
||||
try
|
||||
{
|
||||
newLines = ReadNewLines(logFile, ref seekPosition);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Error reading script log '{File}'", logFile);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var line in newLines)
|
||||
{
|
||||
if (ct.IsCancellationRequested) yield break;
|
||||
var parsed = ParseLine(line);
|
||||
if (Matches(parsed, scriptNameFilter))
|
||||
yield return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static readonly Regex LevelPattern =
|
||||
new(@"\[(?<lvl>VRB|DBG|INF|WRN|ERR|FTL)\]", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
// Serilog compact text format: ScriptName property appears as ScriptName="value" in the output.
|
||||
private static readonly Regex ScriptNamePattern =
|
||||
new(@"ScriptName=""(?<name>[^""]+)""", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
internal static ScriptLogLine ParseLine(string raw)
|
||||
{
|
||||
var level = "INF";
|
||||
var lvlMatch = LevelPattern.Match(raw);
|
||||
if (lvlMatch.Success) level = lvlMatch.Groups["lvl"].Value;
|
||||
|
||||
string? scriptName = null;
|
||||
var snMatch = ScriptNamePattern.Match(raw);
|
||||
if (snMatch.Success) scriptName = snMatch.Groups["name"].Value;
|
||||
|
||||
return new ScriptLogLine(raw, level, scriptName, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
internal static bool Matches(ScriptLogLine line, string? filter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filter)) return true;
|
||||
return line.ScriptName is not null &&
|
||||
line.ScriptName.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the last <paramref name="n"/> lines from <paramref name="path"/> using a
|
||||
/// shared-read stream (so the writer doesn't need an exclusive lock). Returns the lines
|
||||
/// and outputs the final byte offset so the caller can resume from there.
|
||||
/// </summary>
|
||||
internal static List<string> ReadTailLines(string path, int n, out long endPosition)
|
||||
{
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
endPosition = fs.Length;
|
||||
|
||||
if (fs.Length == 0) return [];
|
||||
|
||||
// Walk backwards collecting newlines until we have n+1 occurrences (n lines from end).
|
||||
const int bufferSize = 4096;
|
||||
var position = fs.Length;
|
||||
var lineBreaks = 0;
|
||||
var chunks = new List<byte[]>();
|
||||
|
||||
while (position > 0 && lineBreaks <= n)
|
||||
{
|
||||
var readSize = (int)Math.Min(bufferSize, position);
|
||||
position -= readSize;
|
||||
fs.Seek(position, SeekOrigin.Begin);
|
||||
var buf = new byte[readSize];
|
||||
_ = fs.Read(buf, 0, readSize);
|
||||
chunks.Add(buf);
|
||||
|
||||
foreach (var b in buf)
|
||||
if (b == (byte)'\n') lineBreaks++;
|
||||
|
||||
if (lineBreaks > n) break;
|
||||
}
|
||||
|
||||
// Reassemble bytes in correct order.
|
||||
chunks.Reverse();
|
||||
var allBytes = new byte[chunks.Sum(c => c.Length)];
|
||||
var offset = 0;
|
||||
foreach (var chunk in chunks) { chunk.CopyTo(allBytes, offset); offset += chunk.Length; }
|
||||
|
||||
var fullText = System.Text.Encoding.UTF8.GetString(allBytes);
|
||||
var allLines = fullText.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => l.TrimEnd('\r'))
|
||||
.Where(l => l.Length > 0)
|
||||
.ToArray();
|
||||
return allLines.Length <= n ? allLines.ToList() : allLines[^n..].ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads any bytes appended to <paramref name="path"/> beyond <paramref name="position"/>.
|
||||
/// Updates <paramref name="position"/> to the new end of file.
|
||||
/// </summary>
|
||||
internal static List<string> ReadNewLines(string path, ref long position)
|
||||
{
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
if (fs.Length <= position) return [];
|
||||
|
||||
fs.Seek(position, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(fs, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true);
|
||||
var lines = new List<string>();
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
lines.Add(line);
|
||||
|
||||
position = fs.Position;
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A single parsed script log line sent to the browser.</summary>
|
||||
/// <param name="Raw">Full raw text of the line.</param>
|
||||
/// <param name="Level">Serilog short level: VRB, DBG, INF, WRN, ERR, FTL.</param>
|
||||
/// <param name="ScriptName">Value of the <c>ScriptName</c> structured property, if present.</param>
|
||||
/// <param name="ReceivedAtUtc">Wall-clock time the Admin process forwarded this line.</param>
|
||||
public sealed record ScriptLogLine(
|
||||
string Raw,
|
||||
string Level,
|
||||
string? ScriptName,
|
||||
DateTime ReceivedAtUtc);
|
||||
Reference in New Issue
Block a user