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

View File

@@ -30,6 +30,10 @@
<NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink> <NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink>
<NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink> <NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink>
<NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink> <NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink>
<div class="rail-eyebrow">Scripting</div>
<NavLink class="rail-link" href="/virtual-tags" Match="NavLinkMatch.Prefix">Virtual tags</NavLink>
<NavLink class="rail-link" href="/scripted-alarms" Match="NavLinkMatch.Prefix">Scripted alarms</NavLink>
<NavLink class="rail-link" href="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
<div class="rail-foot"> <div class="rail-foot">
<AuthorizeView> <AuthorizeView>

View File

@@ -39,6 +39,8 @@
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li> <li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li> <li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
<li class="nav-item"><button class="nav-link @Active("scripts")" @onclick='() => _tab = "scripts"'>Scripts</button></li> <li class="nav-item"><button class="nav-link @Active("scripts")" @onclick='() => _tab = "scripts"'>Scripts</button></li>
<li class="nav-item"><button class="nav-link @Active("virtual-tags")" @onclick='() => _tab = "virtual-tags"'>Virtual Tags</button></li>
<li class="nav-item"><button class="nav-link @Active("scripted-alarms")" @onclick='() => _tab = "scripted-alarms"'>Scripted Alarms</button></li>
</ul> </ul>
<div class="row"> <div class="row">
@@ -49,6 +51,8 @@
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> } else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> } else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> } else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "virtual-tags") { <VirtualTagsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
else if (_tab == "scripted-alarms") { <ScriptedAlarmsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<section class="panel rise sticky-top" style="animation-delay:.02s"> <section class="panel rise sticky-top" style="animation-delay:.02s">

View File

@@ -0,0 +1,260 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject ScriptedAlarmService AlarmSvc
@inject ScriptService ScriptSvc
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="panel-head mb-0">Scripted Alarms</h4>
<small class="text-muted">OPC UA Part 9 alarms raised by C# predicate scripts. Additive to driver-native alarm streams.</small>
</div>
<button class="btn btn-primary" @onclick="StartNew">+ New alarm</button>
</div>
@if (_loading)
{
<p class="text-muted">Loading…</p>
}
else if (_alarms.Count == 0 && !_showForm)
{
<section class="panel notice rise" style="animation-delay:.02s">No scripted alarms yet in this draft.</section>
}
else
{
@if (_alarms.Count > 0)
{
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head d-flex justify-content-between align-items-center">
<span>Scripted alarms in draft gen @GenerationId</span>
<span class="tb-count text-muted">@_alarms.Count alarm@(_alarms.Count == 1 ? "" : "s")</span>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Equipment</th>
<th>Type</th>
<th class="num">Severity</th>
<th>Predicate script</th>
<th>Historize</th>
<th>Retain</th>
<th>Enabled</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var a in _alarms)
{
<tr>
<td><span class="mono">@a.Name</span></td>
<td><span class="mono">@a.EquipmentId</span></td>
<td><span class="chip chip-idle">@a.AlarmType</span></td>
<td class="num">@a.Severity <small class="text-muted">@SeverityBand(a.Severity)</small></td>
<td><span class="mono">@(ScriptName(a.PredicateScriptId))</span></td>
<td>
@if (a.HistorizeToAveva) { <span class="chip chip-ok">Aveva</span> }
else { <span class="text-muted">—</span> }
</td>
<td>
@if (a.Retain) { <span class="chip chip-ok">yes</span> }
else { <span class="text-muted">—</span> }
</td>
<td>
@if (a.Enabled) { <span class="chip chip-ok">enabled</span> }
else { <span class="chip chip-idle">disabled</span> }
</td>
<td>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(a.ScriptedAlarmId)">Remove</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
}
@if (_showForm)
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head d-flex justify-content-between align-items-center">
<strong>New scripted alarm</strong>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => _showForm = false">Cancel</button>
</div>
<div class="p-3">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Equipment ID</label>
<input class="form-control form-control-sm" @bind="_draft.EquipmentId"
placeholder="e.g. eq-abc123 — logical FK to Equipment"/>
</div>
<div class="col-md-6">
<label class="form-label">Alarm name <small class="text-muted">(operator-facing display name)</small></label>
<input class="form-control form-control-sm" @bind="_draft.Name"/>
</div>
<div class="col-md-4">
<label class="form-label">Alarm type <small class="text-muted">(OPC UA Part 9 subtype)</small></label>
<select class="form-select form-select-sm" @bind="_draft.AlarmType">
@foreach (var t in AlarmTypes)
{
<option value="@t">@t</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label">
Severity <small class="text-muted">11000 (Low &lt;250, Med &lt;500, High &lt;750, Critical 1000)</small>
</label>
<input type="number" min="1" max="1000" class="form-control form-control-sm" @bind="_draft.Severity"/>
</div>
<div class="col-md-4">
<label class="form-label">Predicate script <small class="text-muted">(returns bool)</small></label>
<select class="form-select form-select-sm" @bind="_draft.PredicateScriptId">
<option value="">— select script —</option>
@foreach (var s in _scripts)
{
<option value="@s.ScriptId">@s.Name (@s.ScriptId)</option>
}
</select>
@if (_scripts.Count == 0)
{
<div class="form-text s-warn">No scripts in this draft — create one in the Scripts tab first.</div>
}
</div>
<div class="col-12">
<label class="form-label">
Message template
<small class="text-muted">Use <code class="mono">{EquipmentPath/TagName}</code> tokens — resolved at alarm emission time</small>
</label>
<input class="form-control form-control-sm" @bind="_draft.MessageTemplate"
placeholder='e.g. "Oven {Plant/Line1/Oven/Temp} exceeds limit {Plant/Line1/Oven/TempLimit}"'/>
</div>
<div class="col-md-4">
<div class="form-check mt-2">
<input type="checkbox" class="form-check-input" id="salHistorize" @bind="_draft.HistorizeToAveva"/>
<label class="form-check-label" for="salHistorize">
Historize to Aveva <small class="text-muted">(SQLite store-and-forward sink)</small>
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check mt-2">
<input type="checkbox" class="form-check-input" id="salRetain" @bind="_draft.Retain"/>
<label class="form-check-label" for="salRetain">
Retain <small class="text-muted">(keep condition visible after clear while un-acked)</small>
</label>
</div>
</div>
</div>
@if (_error is not null)
{
<section class="panel notice mt-3"><span class="s-bad">@_error</span></section>
}
<div class="mt-3">
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
</div>
</div>
</section>
}
@code {
[Parameter] public long GenerationId { get; set; }
[Parameter] public string ClusterId { get; set; } = string.Empty;
private static readonly string[] AlarmTypes =
["AlarmCondition", "LimitAlarm", "OffNormalAlarm", "DiscreteAlarm"];
private bool _loading = true;
private bool _busy;
private bool _showForm;
private List<ScriptedAlarm> _alarms = [];
private List<Script> _scripts = [];
private string? _error;
private ScriptedAlarm _draft = NewDraft();
private static ScriptedAlarm NewDraft() => new()
{
ScriptedAlarmId = string.Empty,
EquipmentId = string.Empty,
Name = string.Empty,
AlarmType = "AlarmCondition",
Severity = 500,
MessageTemplate = string.Empty,
PredicateScriptId = string.Empty,
HistorizeToAveva = true,
Retain = true,
};
protected override async Task OnParametersSetAsync()
{
_loading = true;
_alarms = await AlarmSvc.ListAsync(GenerationId, CancellationToken.None);
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
_loading = false;
}
private void StartNew()
{
_draft = NewDraft();
_error = null;
_showForm = true;
}
private async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(_draft.EquipmentId) ||
string.IsNullOrWhiteSpace(_draft.Name) ||
string.IsNullOrWhiteSpace(_draft.PredicateScriptId))
{
_error = "Equipment ID, Name, and Predicate script are required.";
return;
}
if (_draft.Severity is < 1 or > 1000)
{
_error = "Severity must be between 1 and 1000.";
return;
}
_busy = true;
_error = null;
try
{
await AlarmSvc.AddAsync(
GenerationId,
_draft.EquipmentId, _draft.Name, _draft.AlarmType,
_draft.Severity, _draft.MessageTemplate, _draft.PredicateScriptId,
_draft.HistorizeToAveva, _draft.Retain,
CancellationToken.None);
_showForm = false;
_alarms = await AlarmSvc.ListAsync(GenerationId, CancellationToken.None);
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync(string id)
{
await AlarmSvc.DeleteAsync(GenerationId, id, CancellationToken.None);
_alarms = await AlarmSvc.ListAsync(GenerationId, CancellationToken.None);
}
private string ScriptName(string scriptId)
{
var s = _scripts.FirstOrDefault(x => x.ScriptId == scriptId);
return s is not null ? s.Name : scriptId;
}
private static string SeverityBand(int s) => s switch
{
<= 250 => "(Low)",
<= 500 => "(Medium)",
<= 750 => "(High)",
_ => "(Critical)",
};
}

View File

@@ -0,0 +1,248 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject VirtualTagService VirtualTagSvc
@inject ScriptService ScriptSvc
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="panel-head mb-0">Virtual Tags</h4>
<small class="text-muted">Computed tags driven by C# scripts. Appear in the Equipment browse tree alongside driver tags.</small>
</div>
<button class="btn btn-primary" @onclick="StartNew">+ New virtual tag</button>
</div>
@if (_loading)
{
<p class="text-muted">Loading…</p>
}
else if (_tags.Count == 0 && !_showForm)
{
<section class="panel notice rise" style="animation-delay:.02s">No virtual tags yet in this draft.</section>
}
else
{
@if (_tags.Count > 0)
{
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head d-flex justify-content-between align-items-center">
<span>Virtual tags in draft gen @GenerationId</span>
<span class="tb-count text-muted">@_tags.Count tag@(_tags.Count == 1 ? "" : "s")</span>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Equipment</th>
<th>DataType</th>
<th>Script</th>
<th>Triggers</th>
<th>Historize</th>
<th>Enabled</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var t in _tags)
{
<tr>
<td><span class="mono">@t.Name</span></td>
<td><span class="mono">@t.EquipmentId</span></td>
<td>@t.DataType</td>
<td><span class="mono">@(ScriptName(t.ScriptId))</span></td>
<td>
@if (t.ChangeTriggered) { <span class="chip chip-idle me-1">change</span> }
@if (t.TimerIntervalMs.HasValue) { <span class="chip chip-idle">@t.TimerIntervalMs ms</span> }
</td>
<td>
@if (t.Historize) { <span class="chip chip-ok">yes</span> }
else { <span class="text-muted">—</span> }
</td>
<td>
@if (t.Enabled) { <span class="chip chip-ok">enabled</span> }
else { <span class="chip chip-idle">disabled</span> }
</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => ToggleEnabledAsync(t)">
@(t.Enabled ? "Disable" : "Enable")
</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(t.VirtualTagId)">Remove</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
}
@if (_showForm)
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head d-flex justify-content-between align-items-center">
<strong>New virtual tag</strong>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => _showForm = false">Cancel</button>
</div>
<div class="p-3">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Equipment ID</label>
<input class="form-control form-control-sm" @bind="_draft.EquipmentId"
placeholder="e.g. eq-abc123 — logical FK to Equipment"/>
</div>
<div class="col-md-6">
<label class="form-label">Name <small class="text-muted">(browse name, unique in Equipment)</small></label>
<input class="form-control form-control-sm" @bind="_draft.Name"/>
</div>
<div class="col-md-4">
<label class="form-label">DataType</label>
<select class="form-select form-select-sm" @bind="_draft.DataType">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</select>
</div>
<div class="col-md-8">
<label class="form-label">Script</label>
<select class="form-select form-select-sm" @bind="_draft.ScriptId">
<option value="">— select script —</option>
@foreach (var s in _scripts)
{
<option value="@s.ScriptId">@s.Name (@s.ScriptId)</option>
}
</select>
@if (_scripts.Count == 0)
{
<div class="form-text s-warn">No scripts in this draft — create one in the Scripts tab first.</div>
}
</div>
<div class="col-md-6">
<div class="form-check mt-2">
<input type="checkbox" class="form-check-input" id="vtChangeTriggered" @bind="_draft.ChangeTriggered"/>
<label class="form-check-label" for="vtChangeTriggered">
Change-triggered <small class="text-muted">(re-evaluate on any input change)</small>
</label>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Timer interval (ms) <small class="text-muted">leave blank to disable timer</small></label>
<input type="number" class="form-control form-control-sm" @bind="_timerMs" placeholder="e.g. 5000"/>
</div>
<div class="col-md-6">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="vtHistorize" @bind="_draft.Historize"/>
<label class="form-check-label" for="vtHistorize">
Historize <small class="text-muted">(route evaluations to IHistoryWriter)</small>
</label>
</div>
</div>
</div>
@if (_error is not null)
{
<section class="panel notice mt-3"><span class="s-bad">@_error</span></section>
}
<div class="mt-3">
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
</div>
</div>
</section>
}
@code {
[Parameter] public long GenerationId { get; set; }
[Parameter] public string ClusterId { get; set; } = string.Empty;
private static readonly string[] DataTypes =
["Boolean", "Int32", "Int64", "Float32", "Float64", "String", "DateTime"];
private bool _loading = true;
private bool _busy;
private bool _showForm;
private List<VirtualTag> _tags = [];
private List<Script> _scripts = [];
private string? _error;
// Draft form state (VirtualTag doesn't have update besides Enabled — add-only form)
private VirtualTag _draft = NewDraft();
private int? _timerMs;
private static VirtualTag NewDraft() => new()
{
VirtualTagId = string.Empty,
EquipmentId = string.Empty,
Name = string.Empty,
DataType = "Float32",
ScriptId = string.Empty,
ChangeTriggered = true,
};
protected override async Task OnParametersSetAsync()
{
_loading = true;
_tags = await VirtualTagSvc.ListAsync(GenerationId, CancellationToken.None);
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
_loading = false;
}
private void StartNew()
{
_draft = NewDraft();
_timerMs = null;
_error = null;
_showForm = true;
}
private async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(_draft.EquipmentId) ||
string.IsNullOrWhiteSpace(_draft.Name) ||
string.IsNullOrWhiteSpace(_draft.ScriptId))
{
_error = "Equipment ID, Name, and Script are required.";
return;
}
if (!_draft.ChangeTriggered && _timerMs is null)
{
_error = "At least one trigger must be set (change-triggered or timer).";
return;
}
_busy = true;
_error = null;
try
{
await VirtualTagSvc.AddAsync(
GenerationId,
_draft.EquipmentId, _draft.Name, _draft.DataType, _draft.ScriptId,
_draft.ChangeTriggered, _timerMs, _draft.Historize,
CancellationToken.None);
_showForm = false;
_tags = await VirtualTagSvc.ListAsync(GenerationId, CancellationToken.None);
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync(string id)
{
await VirtualTagSvc.DeleteAsync(GenerationId, id, CancellationToken.None);
_tags = await VirtualTagSvc.ListAsync(GenerationId, CancellationToken.None);
}
private async Task ToggleEnabledAsync(VirtualTag t)
{
await VirtualTagSvc.UpdateEnabledAsync(GenerationId, t.VirtualTagId, !t.Enabled, CancellationToken.None);
_tags = await VirtualTagSvc.ListAsync(GenerationId, CancellationToken.None);
}
private string ScriptName(string scriptId)
{
var s = _scripts.FirstOrDefault(x => x.ScriptId == scriptId);
return s is not null ? s.Name : scriptId;
}
}

View File

@@ -0,0 +1,238 @@
@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
@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 ??= new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/hubs/script-log"))
.WithAutomaticReconnect()
.Build();
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;
}
}
}

View File

@@ -0,0 +1,191 @@
@page "/scripted-alarms"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject ScriptedAlarmService AlarmSvc
@inject GenerationService GenerationSvc
@inject ClusterService ClusterSvc
@inject NavigationManager Nav
<h1 class="page-title">Scripted Alarms</h1>
<p class="text-muted">
OPC UA Part 9 alarms raised by C# predicate scripts. To author scripted alarms, open a cluster
draft and use the <strong>Scripted Alarms</strong> tab in the draft editor.
This view lists all scripted alarms across clusters and generations for reference.
</p>
<div class="toolbar mb-3">
<select class="form-select form-select-sm tb-state" @bind="_filterClusterId" @bind:after="OnClusterChangedAsync">
<option value="">— all clusters —</option>
@foreach (var c in _clusters)
{
<option value="@c.ClusterId">@c.Name (@c.ClusterId)</option>
}
</select>
<select class="form-select form-select-sm tb-state ms-2" @bind="_filterGenerationId" @bind:after="LoadAlarmsAsync"
disabled="@(string.IsNullOrEmpty(_filterClusterId))">
<option value="0">— all generations —</option>
@foreach (var g in _generations)
{
<option value="@g.GenerationId">gen @g.GenerationId (@g.Status) @(g.PublishedAt?.ToString("yyyy-MM-dd") ?? "")</option>
}
</select>
<span class="spacer"></span>
@if (_alarms is not null) { <span class="tb-count">@_alarms.Count alarm@(_alarms.Count == 1 ? "" : "s")</span> }
</div>
@if (_loading)
{
<p class="text-muted">Loading…</p>
}
else if (_alarms is null || _alarms.Count == 0)
{
<section class="panel notice rise" style="animation-delay:.02s">
No scripted alarms found for the selected filter.
Open a cluster draft and use the <strong>Scripted Alarms</strong> tab to author scripted alarms.
</section>
}
else
{
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Scripted alarms</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Equipment</th>
<th>Type</th>
<th class="num">Severity</th>
<th>Message template</th>
<th>Predicate script</th>
<th>Historize</th>
<th>Retain</th>
<th>Enabled</th>
<th class="num">Generation</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var a in _alarms)
{
<tr>
<td><span class="mono">@a.Name</span></td>
<td><span class="mono">@a.EquipmentId</span></td>
<td><span class="chip chip-idle">@a.AlarmType</span></td>
<td class="num">@a.Severity <small class="text-muted">@SeverityBand(a.Severity)</small></td>
<td class="text-truncate" style="max-width:18rem" title="@a.MessageTemplate">
<span class="mono small">@a.MessageTemplate</span>
</td>
<td><span class="mono">@a.PredicateScriptId</span></td>
<td>
@if (a.HistorizeToAveva) { <span class="chip chip-ok">Aveva</span> }
else { <span class="text-muted">—</span> }
</td>
<td>
@if (a.Retain) { <span class="chip chip-ok">yes</span> }
else { <span class="text-muted">—</span> }
</td>
<td>
@if (a.Enabled) { <span class="chip chip-ok">enabled</span> }
else { <span class="chip chip-idle">disabled</span> }
</td>
<td class="num">@a.GenerationId</td>
<td>
@if (DraftLink(a.GenerationId) is { } link)
{
<a class="btn btn-sm btn-outline-secondary" href="@link">Edit draft</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
private List<ServerCluster> _clusters = [];
private List<ConfigGeneration> _generations = [];
private List<ScriptedAlarm>? _alarms;
private string _filterClusterId = string.Empty;
private long _filterGenerationId;
private bool _loading;
private Dictionary<long, (string clusterId, bool isDraft)> _genMap = [];
protected override async Task OnInitializedAsync()
{
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
await LoadAlarmsAsync();
}
private async Task OnClusterChangedAsync()
{
_filterGenerationId = 0;
_generations = string.IsNullOrEmpty(_filterClusterId)
? []
: await GenerationSvc.ListRecentAsync(_filterClusterId, 20, CancellationToken.None);
await LoadAlarmsAsync();
}
private async Task LoadAlarmsAsync()
{
_loading = true;
_alarms = null;
try
{
var all = new List<ScriptedAlarm>();
_genMap = [];
IEnumerable<ConfigGeneration> gens;
if (!string.IsNullOrEmpty(_filterClusterId) && _filterGenerationId != 0)
{
gens = _generations.Where(g => g.GenerationId == _filterGenerationId);
}
else if (!string.IsNullOrEmpty(_filterClusterId))
{
gens = await GenerationSvc.ListRecentAsync(_filterClusterId, 20, CancellationToken.None);
_generations = gens.ToList();
}
else
{
var allGens = new List<ConfigGeneration>();
foreach (var c in _clusters)
{
var cGens = await GenerationSvc.ListRecentAsync(c.ClusterId, 5, CancellationToken.None);
allGens.AddRange(cGens);
}
gens = allGens;
}
foreach (var g in gens)
{
_genMap[g.GenerationId] = (g.ClusterId, g.Status == GenerationStatus.Draft);
var alarms = await AlarmSvc.ListAsync(g.GenerationId, CancellationToken.None);
all.AddRange(alarms);
}
_alarms = all;
}
finally { _loading = false; }
}
private string? DraftLink(long generationId)
{
if (_genMap.TryGetValue(generationId, out var info) && info.isDraft)
return $"/clusters/{info.clusterId}/draft/{generationId}";
return null;
}
private static string SeverityBand(int s) => s switch
{
<= 250 => "(Low)",
<= 500 => "(Medium)",
<= 750 => "(High)",
_ => "(Critical)",
};
}

View File

@@ -0,0 +1,182 @@
@page "/virtual-tags"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject VirtualTagService VirtualTagSvc
@inject GenerationService GenerationSvc
@inject ClusterService ClusterSvc
@inject NavigationManager Nav
<h1 class="page-title">Virtual Tags</h1>
<p class="text-muted">
Computed tags driven by C# scripts. To author virtual tags, open a cluster draft and use the
<strong>Virtual Tags</strong> tab in the draft editor.
This view lists all virtual tags across clusters and generations for reference.
</p>
<div class="toolbar mb-3">
<select class="form-select form-select-sm tb-state" @bind="_filterClusterId" @bind:after="OnClusterChangedAsync">
<option value="">— all clusters —</option>
@foreach (var c in _clusters)
{
<option value="@c.ClusterId">@c.Name (@c.ClusterId)</option>
}
</select>
<select class="form-select form-select-sm tb-state ms-2" @bind="_filterGenerationId" @bind:after="LoadTagsAsync"
disabled="@(string.IsNullOrEmpty(_filterClusterId))">
<option value="0">— all generations —</option>
@foreach (var g in _generations)
{
<option value="@g.GenerationId">gen @g.GenerationId (@g.Status) @(g.PublishedAt?.ToString("yyyy-MM-dd") ?? "")</option>
}
</select>
<span class="spacer"></span>
@if (_tags is not null) { <span class="tb-count">@_tags.Count tag@(_tags.Count == 1 ? "" : "s")</span> }
</div>
@if (_loading)
{
<p class="text-muted">Loading…</p>
}
else if (_tags is null || _tags.Count == 0)
{
<section class="panel notice rise" style="animation-delay:.02s">
No virtual tags found for the selected filter.
Open a cluster draft and use the <strong>Virtual Tags</strong> tab to author virtual tags.
</section>
}
else
{
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Virtual tags</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Equipment</th>
<th>DataType</th>
<th>Script</th>
<th>Triggers</th>
<th>Historize</th>
<th>Enabled</th>
<th class="num">Generation</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var t in _tags)
{
<tr>
<td><span class="mono">@t.Name</span></td>
<td><span class="mono">@t.EquipmentId</span></td>
<td>@t.DataType</td>
<td><span class="mono">@t.ScriptId</span></td>
<td>
@if (t.ChangeTriggered) { <span class="chip chip-idle me-1">change</span> }
@if (t.TimerIntervalMs.HasValue) { <span class="chip chip-idle">@t.TimerIntervalMs ms</span> }
</td>
<td>
@if (t.Historize) { <span class="chip chip-ok">yes</span> }
else { <span class="text-muted">—</span> }
</td>
<td>
@if (t.Enabled) { <span class="chip chip-ok">enabled</span> }
else { <span class="chip chip-idle">disabled</span> }
</td>
<td class="num">@t.GenerationId</td>
<td>
@if (DraftGenId(t.GenerationId) is { } draftUrl)
{
<a class="btn btn-sm btn-outline-secondary" href="@draftUrl">Edit draft</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
private List<ServerCluster> _clusters = [];
private List<ConfigGeneration> _generations = [];
private List<VirtualTag>? _tags;
private string _filterClusterId = string.Empty;
private long _filterGenerationId;
private bool _loading;
// Map generationId → (clusterId, isDraft) for the "Edit draft" link
private Dictionary<long, (string clusterId, bool isDraft)> _genMap = [];
protected override async Task OnInitializedAsync()
{
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
await LoadTagsAsync();
}
private async Task OnClusterChangedAsync()
{
_filterGenerationId = 0;
_generations = string.IsNullOrEmpty(_filterClusterId)
? []
: await GenerationSvc.ListRecentAsync(_filterClusterId, 20, CancellationToken.None);
await LoadTagsAsync();
}
private async Task LoadTagsAsync()
{
_loading = true;
_tags = null;
try
{
var allTags = new List<VirtualTag>();
_genMap = [];
// Determine which generations to query
IEnumerable<ConfigGeneration> gens;
if (!string.IsNullOrEmpty(_filterClusterId) && _filterGenerationId != 0)
{
// specific cluster + specific generation
gens = _generations.Where(g => g.GenerationId == _filterGenerationId);
}
else if (!string.IsNullOrEmpty(_filterClusterId))
{
// specific cluster, all recent generations
gens = await GenerationSvc.ListRecentAsync(_filterClusterId, 20, CancellationToken.None);
_generations = gens.ToList();
}
else
{
// all clusters — load generations per cluster
var allGens = new List<ConfigGeneration>();
foreach (var c in _clusters)
{
var cGens = await GenerationSvc.ListRecentAsync(c.ClusterId, 5, CancellationToken.None);
allGens.AddRange(cGens);
}
gens = allGens;
}
foreach (var g in gens)
{
_genMap[g.GenerationId] = (g.ClusterId, g.Status == ZB.MOM.WW.OtOpcUa.Configuration.Enums.GenerationStatus.Draft);
var tags = await VirtualTagSvc.ListAsync(g.GenerationId, CancellationToken.None);
allTags.AddRange(tags);
}
_tags = allTags;
}
finally { _loading = false; }
}
private string? DraftGenId(long generationId)
{
if (_genMap.TryGetValue(generationId, out var info) && info.isDraft)
return $"/clusters/{info.clusterId}/draft/{generationId}";
return null;
}
}

View File

@@ -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);

View File

@@ -136,6 +136,7 @@ app.MapPost("/auth/logout", async (HttpContext ctx) =>
app.MapHub<FleetStatusHub>("/hubs/fleet"); app.MapHub<FleetStatusHub>("/hubs/fleet");
app.MapHub<AlertHub>("/hubs/alerts"); app.MapHub<AlertHub>("/hubs/alerts");
app.MapHub<ScriptLogHub>("/hubs/script-log");
if (metricsEnabled) if (metricsEnabled)
{ {

View File

@@ -0,0 +1,198 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Unit tests for <see cref="ScriptLogHub"/> helper logic — line parsing, filter matching,
/// and the tail/append file reading utilities. The SignalR streaming method itself
/// (TailLogAsync) is not integration-tested here; the helpers are tested in isolation.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptLogHubTests
{
// ── ParseLine ──────────────────────────────────────────────────────────────
[Fact]
public void ParseLine_extracts_INF_level_from_serilog_format()
{
var line = ScriptLogHub.ParseLine("[12:34:56 INF] Script ran successfully");
line.Level.ShouldBe("INF");
}
[Fact]
public void ParseLine_extracts_WRN_level()
{
var line = ScriptLogHub.ParseLine("2026-05-18T12:34:56.000Z [WRN] Script timed out");
line.Level.ShouldBe("WRN");
}
[Fact]
public void ParseLine_extracts_ERR_level()
{
var line = ScriptLogHub.ParseLine("[ERR] NullReferenceException in script");
line.Level.ShouldBe("ERR");
}
[Fact]
public void ParseLine_defaults_to_INF_when_no_level_token()
{
var line = ScriptLogHub.ParseLine("Some unformatted log text with no level");
line.Level.ShouldBe("INF");
}
[Fact]
public void ParseLine_extracts_ScriptName_property()
{
var raw = """[INF] Evaluation complete ScriptName="line-rate-calc" Value=42""";
var line = ScriptLogHub.ParseLine(raw);
line.ScriptName.ShouldBe("line-rate-calc");
}
[Fact]
public void ParseLine_ScriptName_is_null_when_property_absent()
{
var raw = "[INF] Server started";
var line = ScriptLogHub.ParseLine(raw);
line.ScriptName.ShouldBeNull();
}
[Fact]
public void ParseLine_preserves_Raw_text_unchanged()
{
var raw = "[WRN] Script error ScriptName=\"my-alarm\" Details=\"bad value\"";
var line = ScriptLogHub.ParseLine(raw);
line.Raw.ShouldBe(raw);
}
// ── Matches ────────────────────────────────────────────────────────────────
[Fact]
public void Matches_null_filter_accepts_all_lines()
{
var line = new ScriptLogLine("raw", "INF", null, DateTime.UtcNow);
ScriptLogHub.Matches(line, null).ShouldBeTrue();
}
[Fact]
public void Matches_empty_filter_accepts_all_lines()
{
var line = new ScriptLogLine("raw", "INF", "some-script", DateTime.UtcNow);
ScriptLogHub.Matches(line, "").ShouldBeTrue();
}
[Fact]
public void Matches_whitespace_filter_accepts_all_lines()
{
var line = new ScriptLogLine("raw", "INF", null, DateTime.UtcNow);
ScriptLogHub.Matches(line, " ").ShouldBeTrue();
}
[Fact]
public void Matches_filter_matches_script_name_case_insensitive()
{
var line = new ScriptLogLine("raw", "INF", "line-rate-calc", DateTime.UtcNow);
ScriptLogHub.Matches(line, "Line-Rate").ShouldBeTrue();
}
[Fact]
public void Matches_filter_rejects_line_with_different_script_name()
{
var line = new ScriptLogLine("raw", "INF", "oven-temp-alarm", DateTime.UtcNow);
ScriptLogHub.Matches(line, "line-rate").ShouldBeFalse();
}
[Fact]
public void Matches_filter_rejects_line_with_null_script_name()
{
var line = new ScriptLogLine("raw", "INF", null, DateTime.UtcNow);
ScriptLogHub.Matches(line, "line-rate").ShouldBeFalse();
}
[Fact]
public void Matches_filter_supports_partial_match()
{
var line = new ScriptLogLine("raw", "INF", "line-rate-calc", DateTime.UtcNow);
ScriptLogHub.Matches(line, "rate").ShouldBeTrue();
}
// ── ReadTailLines / ReadNewLines ──────────────────────────────────────────
[Fact]
public void ReadTailLines_returns_empty_list_for_empty_file()
{
var path = Path.GetTempFileName();
try
{
File.WriteAllText(path, string.Empty);
var lines = ScriptLogHub.ReadTailLines(path, 50, out var pos);
lines.ShouldBeEmpty();
pos.ShouldBe(0);
}
finally { File.Delete(path); }
}
[Fact]
public void ReadTailLines_returns_all_lines_when_fewer_than_n()
{
var path = Path.GetTempFileName();
try
{
File.WriteAllLines(path, ["line1", "line2", "line3"]);
var lines = ScriptLogHub.ReadTailLines(path, 50, out _);
lines.ShouldContain("line1");
lines.ShouldContain("line2");
lines.ShouldContain("line3");
}
finally { File.Delete(path); }
}
[Fact]
public void ReadTailLines_returns_last_n_lines_when_file_is_large()
{
var path = Path.GetTempFileName();
try
{
var allLines = Enumerable.Range(1, 20).Select(i => $"line{i}").ToArray();
File.WriteAllLines(path, allLines);
var lines = ScriptLogHub.ReadTailLines(path, 5, out _);
lines.Count.ShouldBe(5);
lines[^1].ShouldBe("line20");
}
finally { File.Delete(path); }
}
[Fact]
public void ReadNewLines_returns_empty_when_nothing_appended()
{
var path = Path.GetTempFileName();
try
{
File.WriteAllText(path, "existing content\n");
ScriptLogHub.ReadTailLines(path, 10, out var pos); // seed position
var newLines = ScriptLogHub.ReadNewLines(path, ref pos);
newLines.ShouldBeEmpty();
}
finally { File.Delete(path); }
}
[Fact]
public void ReadNewLines_returns_appended_lines()
{
var path = Path.GetTempFileName();
try
{
File.WriteAllText(path, "existing\n");
ScriptLogHub.ReadTailLines(path, 10, out var pos); // set position to end
// Append new content
File.AppendAllText(path, "appended-line-1\nappended-line-2\n");
var newLines = ScriptLogHub.ReadNewLines(path, ref pos);
newLines.ShouldContain("appended-line-1");
newLines.ShouldContain("appended-line-2");
}
finally { File.Delete(path); }
}
}