feat(admin-ui): add /virtual-tags, /scripted-alarms, and /script-log pages (tasks #25, #26, #27)

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:
Joseph Doherty
2026-05-18 05:49:05 -04:00
parent bc8ff7a5fe
commit 41f133a337
10 changed files with 1548 additions and 0 deletions
@@ -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);