Merge branch 'feat/wave4-phase7'
Wave 4 — Phase 7 gap closure + flaky-test stabilization: - #24 route OPC UA Part 9 Acknowledge/Confirm to ScriptedAlarmEngine - #25/#26/#27 /virtual-tags + /scripted-alarms pages + /script-log viewer - #28 RingBufferHistoryWriter — production IHistoryWriter for virtual tags - #29 stabilize 3 flaky compliance-gate tests (root-cause fixes)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">1–1000 (Low <250, Med <500, High <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)",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
222
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Hubs/ScriptLogHub.cs
Normal file
222
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Hubs/ScriptLogHub.cs
Normal 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);
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ using Opc.Ua.Server;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.History;
|
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
||||||
|
// AlarmConditionState exists in both Opc.Ua (the OPC UA stack's node-state class) and
|
||||||
|
// ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms (the engine's persisted-state record). Alias the
|
||||||
|
// OPC UA one so ConditionSink / VariableHandle keep their existing unqualified usage.
|
||||||
|
using OpcAlarmConditionState = Opc.Ua.AlarmConditionState;
|
||||||
// Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation
|
// Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation
|
||||||
// point) that collides with Opc.Ua.HistoryReadResult (service-layer per-node result). We
|
// point) that collides with Opc.Ua.HistoryReadResult (service-layer per-node result). We
|
||||||
// assign driver-side results to an explicitly-aliased local and construct only the service
|
// assign driver-side results to an explicitly-aliased local and construct only the service
|
||||||
@@ -22,7 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
|||||||
/// into OPC UA nodes. Implements <see cref="IAddressSpaceBuilder"/> itself so
|
/// into OPC UA nodes. Implements <see cref="IAddressSpaceBuilder"/> itself so
|
||||||
/// <c>GenericDriverNodeManager.BuildAddressSpaceAsync</c> can stream nodes directly into the
|
/// <c>GenericDriverNodeManager.BuildAddressSpaceAsync</c> can stream nodes directly into the
|
||||||
/// OPC UA server's namespace. PR 15's <c>MarkAsAlarmCondition</c> hook creates a sibling
|
/// OPC UA server's namespace. PR 15's <c>MarkAsAlarmCondition</c> hook creates a sibling
|
||||||
/// <see cref="AlarmConditionState"/> node per alarm-flagged variable; subsequent driver
|
/// <see cref="OpcAlarmConditionState"/> node per alarm-flagged variable; subsequent driver
|
||||||
/// <c>OnAlarmEvent</c> pushes land through the returned sink to drive Activate /
|
/// <c>OnAlarmEvent</c> pushes land through the returned sink to drive Activate /
|
||||||
/// Acknowledge / Deactivate transitions.
|
/// Acknowledge / Deactivate transitions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -106,12 +111,27 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
private readonly Dictionary<string, ConditionSink> _conditionSinks = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, ConditionSink> _conditionSinks = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private EventHandler<AlarmConditionTransition>? _alarmTransitionHandler;
|
private EventHandler<AlarmConditionTransition>? _alarmTransitionHandler;
|
||||||
|
|
||||||
|
// Task #24 — Phase 7 Gap 1: route OPC UA Part 9 Acknowledge / Confirm method calls on
|
||||||
|
// scripted alarm condition nodes to the ScriptedAlarmEngine so the engine state machine
|
||||||
|
// advances with the authenticated principal. When null, scripted-alarm method calls fall
|
||||||
|
// through to the stack's built-in handler (which updates the OPC UA node but does not
|
||||||
|
// reach the engine — the pre-task-24 gap).
|
||||||
|
//
|
||||||
|
// _scriptedAlarmIdByConditionNodeId maps the condition node's string identifier
|
||||||
|
// (e.g. "sal-abc123.Condition") → the ScriptedAlarmId the engine addresses by
|
||||||
|
// (e.g. "sal-abc123"). Populated in MarkAsAlarmCondition when the parent variable
|
||||||
|
// carries NodeSourceKind.ScriptedAlarm.
|
||||||
|
private readonly ScriptedAlarmEngine? _scriptedAlarmEngine;
|
||||||
|
private readonly Dictionary<string, string> _scriptedAlarmIdByConditionNodeId
|
||||||
|
= new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
|
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
|
||||||
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
|
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
|
||||||
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
|
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
|
||||||
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null,
|
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null,
|
||||||
IHistoryRouter? historyRouter = null,
|
IHistoryRouter? historyRouter = null,
|
||||||
AlarmConditionService? alarmService = null)
|
AlarmConditionService? alarmService = null,
|
||||||
|
ScriptedAlarmEngine? scriptedAlarmEngine = null)
|
||||||
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
|
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
|
||||||
{
|
{
|
||||||
_driver = driver;
|
_driver = driver;
|
||||||
@@ -125,6 +145,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
_scriptedAlarmReadable = scriptedAlarmReadable;
|
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||||
_historyRouter = historyRouter;
|
_historyRouter = historyRouter;
|
||||||
_alarmService = alarmService;
|
_alarmService = alarmService;
|
||||||
|
_scriptedAlarmEngine = scriptedAlarmEngine;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
if (_alarmService is not null)
|
if (_alarmService is not null)
|
||||||
@@ -601,9 +622,116 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
IList<ServiceResult> errors)
|
IList<ServiceResult> errors)
|
||||||
{
|
{
|
||||||
GateCallMethodRequests(methodsToCall, errors, context.UserIdentity, _authzGate, _scopeResolver);
|
GateCallMethodRequests(methodsToCall, errors, context.UserIdentity, _authzGate, _scopeResolver);
|
||||||
|
|
||||||
|
// Task #24 — Phase 7 Gap 1: route Part 9 Acknowledge / Confirm calls that target
|
||||||
|
// scripted alarm condition nodes directly to the ScriptedAlarmEngine. The engine
|
||||||
|
// advances its Part 9 state machine with the authenticated principal (audit
|
||||||
|
// requirement), persists state, and emits the transition event through
|
||||||
|
// ScriptedAlarmSource so OPC UA alarm subscribers see the correct AckedState /
|
||||||
|
// ConfirmedState change. base.Call is skipped for handled slots so the stack's
|
||||||
|
// built-in handler (which updates the OPC UA node but doesn't call the engine)
|
||||||
|
// does not double-fire.
|
||||||
|
if (_scriptedAlarmEngine is not null)
|
||||||
|
{
|
||||||
|
RouteScriptedAlarmMethodCalls(context.UserIdentity, methodsToCall, results, errors,
|
||||||
|
_scriptedAlarmEngine, _scriptedAlarmIdByConditionNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
base.Call(context, methodsToCall, results, errors);
|
base.Call(context, methodsToCall, results, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Intercepts Part 9 Acknowledge / Confirm <see cref="CallMethodRequest"/> slots that
|
||||||
|
/// target scripted alarm condition nodes and routes them to the
|
||||||
|
/// <see cref="ScriptedAlarmEngine"/>. Slots that are handled have their
|
||||||
|
/// <paramref name="errors"/> entry set to <see cref="ServiceResult.Good"/> and are
|
||||||
|
/// not touched by the caller's subsequent <c>base.Call</c> invocation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The OPC UA Part 9 Acknowledge method signature is:
|
||||||
|
/// InputArguments[0] = EventId (ByteString, ignored — scripted alarms identify by
|
||||||
|
/// ConditionId, not EventId), InputArguments[1] = Comment (LocalizedText).
|
||||||
|
/// Confirm has the same shape. Missing or null comment is treated as empty string.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// User identity is extracted from <paramref name="userIdentity"/>'s
|
||||||
|
/// <c>DisplayName</c>; when absent (anonymous session) the fallback is
|
||||||
|
/// <c>"opcua-client"</c> so every audit entry carries an identity. Authenticated
|
||||||
|
/// LDAP sessions populate DisplayName during <c>OtOpcUaServer.OnImpersonateUser</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Extracted as a pure function for unit-testability — all dependencies are
|
||||||
|
/// passed explicitly; no closed-over state. The caller passes
|
||||||
|
/// <c>context.UserIdentity</c> from the <see cref="Call"/> override.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static void RouteScriptedAlarmMethodCalls(
|
||||||
|
IUserIdentity? userIdentity,
|
||||||
|
IList<CallMethodRequest> methodsToCall,
|
||||||
|
IList<CallMethodResult> results,
|
||||||
|
IList<ServiceResult> errors,
|
||||||
|
ScriptedAlarmEngine engine,
|
||||||
|
IReadOnlyDictionary<string, string> conditionIdToAlarmId)
|
||||||
|
{
|
||||||
|
var user = userIdentity?.DisplayName;
|
||||||
|
if (string.IsNullOrWhiteSpace(user)) user = "opcua-client";
|
||||||
|
|
||||||
|
for (var i = 0; i < methodsToCall.Count; i++)
|
||||||
|
{
|
||||||
|
// Skip slots already errored (gate denied or pre-populated by the stack).
|
||||||
|
if (errors[i] is not null && ServiceResult.IsBad(errors[i])) continue;
|
||||||
|
|
||||||
|
var request = methodsToCall[i];
|
||||||
|
|
||||||
|
// Only handle the two well-known Part 9 method ids.
|
||||||
|
var isAcknowledge = request.MethodId == MethodIds.AcknowledgeableConditionType_Acknowledge;
|
||||||
|
var isConfirm = request.MethodId == MethodIds.AcknowledgeableConditionType_Confirm;
|
||||||
|
if (!isAcknowledge && !isConfirm) continue;
|
||||||
|
|
||||||
|
// ObjectId must be a string identifier so we can look it up in the index.
|
||||||
|
if (request.ObjectId.Identifier is not string conditionKey) continue;
|
||||||
|
|
||||||
|
if (!conditionIdToAlarmId.TryGetValue(conditionKey, out var alarmId)) continue;
|
||||||
|
|
||||||
|
// Extract the operator comment from InputArguments[1] (LocalizedText).
|
||||||
|
// InputArguments[0] is EventId (ByteString) — we address by alarmId, not EventId.
|
||||||
|
// InputArguments elements are Variant-boxed values; unbox via .Value before casting.
|
||||||
|
string? comment = null;
|
||||||
|
if (request.InputArguments?.Count >= 2
|
||||||
|
&& request.InputArguments[1].Value is LocalizedText lt)
|
||||||
|
comment = lt.Text;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (isAcknowledge)
|
||||||
|
engine.AcknowledgeAsync(alarmId, user, comment, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
else
|
||||||
|
engine.ConfirmAsync(alarmId, user, comment, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// Mark the slot as handled so base.Call skips it. A pre-populated Good
|
||||||
|
// result (not null and not Bad) is the signal the base class uses to
|
||||||
|
// skip per-slot dispatch — set StatusCode to Good explicitly.
|
||||||
|
results[i] = new CallMethodResult { StatusCode = StatusCodes.Good };
|
||||||
|
errors[i] = ServiceResult.Good;
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
// Unknown alarmId or invalid state (e.g. already acknowledged) — surface
|
||||||
|
// as BadInvalidArgument so the OPC UA client sees a meaningful status.
|
||||||
|
errors[i] = new ServiceResult(StatusCodes.BadInvalidArgument,
|
||||||
|
ex.Message, ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errors[i] = new ServiceResult(StatusCodes.BadInternalError,
|
||||||
|
ex.Message, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure-function gate for a batch of <see cref="CallMethodRequest"/>. Pre-populates
|
/// Pure-function gate for a batch of <see cref="CallMethodRequest"/>. Pre-populates
|
||||||
/// <paramref name="errors"/> slots with <see cref="StatusCodes.BadUserAccessDenied"/>
|
/// <paramref name="errors"/> slots with <see cref="StatusCodes.BadUserAccessDenied"/>
|
||||||
@@ -774,7 +902,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
{
|
{
|
||||||
lock (_owner.Lock)
|
lock (_owner.Lock)
|
||||||
{
|
{
|
||||||
var alarm = new AlarmConditionState(_variable)
|
var alarm = new OpcAlarmConditionState(_variable)
|
||||||
{
|
{
|
||||||
SymbolicName = _variable.BrowseName.Name + "_Condition",
|
SymbolicName = _variable.BrowseName.Name + "_Condition",
|
||||||
ReferenceTypeId = ReferenceTypeIds.HasComponent,
|
ReferenceTypeId = ReferenceTypeIds.HasComponent,
|
||||||
@@ -822,6 +950,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
|
|
||||||
var sink = new ConditionSink(_owner, alarm);
|
var sink = new ConditionSink(_owner, alarm);
|
||||||
|
|
||||||
|
// Task #24 — register the condition nodeId → ScriptedAlarmId mapping so the
|
||||||
|
// Call override can route Acknowledge/Confirm invocations to the engine.
|
||||||
|
// The condition's string identifier is "{FullReference}.Condition"; the engine
|
||||||
|
// addresses alarms by ScriptedAlarmId (= FullReference for scripted alarms,
|
||||||
|
// because EquipmentNodeWalker sets FullName = ScriptedAlarmId on the attr).
|
||||||
|
if (_owner._scriptedAlarmEngine is not null
|
||||||
|
&& _owner._sourceByFullRef.TryGetValue(FullReference, out var varSource)
|
||||||
|
&& varSource == NodeSourceKind.ScriptedAlarm)
|
||||||
|
{
|
||||||
|
var conditionKey = alarm.NodeId.Identifier?.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(conditionKey))
|
||||||
|
_owner._scriptedAlarmIdByConditionNodeId[conditionKey!] = FullReference;
|
||||||
|
}
|
||||||
|
|
||||||
// PR 2.3 — when the server-level alarm-condition service is wired, register
|
// PR 2.3 — when the server-level alarm-condition service is wired, register
|
||||||
// this condition with it so the state machine runs server-side. The sink-map
|
// this condition with it so the state machine runs server-side. The sink-map
|
||||||
// entry routes future TransitionRaised events back to this OPC UA node.
|
// entry routes future TransitionRaised events back to this OPC UA node.
|
||||||
@@ -884,7 +1026,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
|
private sealed class ConditionSink(DriverNodeManager owner, OpcAlarmConditionState alarm)
|
||||||
: IAlarmConditionSink
|
: IAlarmConditionSink
|
||||||
{
|
{
|
||||||
public void OnTransition(AlarmEventArgs args)
|
public void OnTransition(AlarmEventArgs args)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.History;
|
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.Observability;
|
using ZB.MOM.WW.OtOpcUa.Server.Observability;
|
||||||
@@ -41,6 +42,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
// the host has been DI-constructed (task #246).
|
// the host has been DI-constructed (task #246).
|
||||||
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable;
|
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable;
|
||||||
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable;
|
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable;
|
||||||
|
// Task #24 — Phase 7 Gap 1. The engine is passed to every DriverNodeManager so OPC UA
|
||||||
|
// Part 9 Acknowledge / Confirm method calls on scripted alarm condition nodes reach the
|
||||||
|
// engine with the authenticated principal instead of falling through to the stack's
|
||||||
|
// default Part 9 no-op handler.
|
||||||
|
private ScriptedAlarmEngine? _scriptedAlarmEngine;
|
||||||
|
|
||||||
// PR 1+2.W — server-level singletons. Threaded through to OtOpcUaServer + every
|
// PR 1+2.W — server-level singletons. Threaded through to OtOpcUaServer + every
|
||||||
// DriverNodeManager. Default null preserves existing test construction sites that
|
// DriverNodeManager. Default null preserves existing test construction sites that
|
||||||
@@ -90,21 +96,31 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
public OtOpcUaServer? Server => _server;
|
public OtOpcUaServer? Server => _server;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Late-bind the Phase 7 engine-backed <c>IReadable</c> sources. Must be
|
/// Late-bind the Phase 7 engine-backed <c>IReadable</c> sources and the
|
||||||
/// called BEFORE <see cref="StartAsync"/> — once the OPC UA server starts, the
|
/// <see cref="ScriptedAlarmEngine"/>. Must be called BEFORE <see cref="StartAsync"/>
|
||||||
/// <see cref="OtOpcUaServer"/> ctor captures the field values + per-node
|
/// — once the OPC UA server starts, the <see cref="OtOpcUaServer"/> ctor captures
|
||||||
/// <see cref="DriverNodeManager"/>s are constructed. Calling this after start has
|
/// the field values + per-node <see cref="DriverNodeManager"/>s are constructed.
|
||||||
/// no effect on already-materialized node managers.
|
/// Calling this after start has no effect on already-materialized node managers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="virtualReadable">Virtual-tag engine read adapter; null when no virtual tags.</param>
|
||||||
|
/// <param name="scriptedAlarmReadable">Scripted-alarm engine read adapter; null when no scripted alarms.</param>
|
||||||
|
/// <param name="alarmEngine">
|
||||||
|
/// The <see cref="ScriptedAlarmEngine"/> instance (task #24). When non-null,
|
||||||
|
/// <c>DriverNodeManager</c> routes OPC UA Part 9 Acknowledge/Confirm method calls
|
||||||
|
/// on scripted alarm condition nodes directly to the engine with the authenticated
|
||||||
|
/// principal, producing correct audit entries and state-machine transitions.
|
||||||
|
/// </param>
|
||||||
public void SetPhase7Sources(
|
public void SetPhase7Sources(
|
||||||
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable,
|
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable,
|
||||||
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable)
|
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable,
|
||||||
|
ScriptedAlarmEngine? alarmEngine = null)
|
||||||
{
|
{
|
||||||
if (_server is not null)
|
if (_server is not null)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Phase 7 sources must be set before OpcUaApplicationHost.StartAsync; the OtOpcUaServer + DriverNodeManagers have already captured the previous values.");
|
"Phase 7 sources must be set before OpcUaApplicationHost.StartAsync; the OtOpcUaServer + DriverNodeManagers have already captured the previous values.");
|
||||||
_virtualReadable = virtualReadable;
|
_virtualReadable = virtualReadable;
|
||||||
_scriptedAlarmReadable = scriptedAlarmReadable;
|
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||||
|
_scriptedAlarmEngine = alarmEngine;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -149,7 +165,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
|
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
|
||||||
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
|
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
|
||||||
anonymousRoles: _options.AnonymousRoles,
|
anonymousRoles: _options.AnonymousRoles,
|
||||||
historyRouter: _historyRouter, alarmConditionService: _alarmConditionService);
|
historyRouter: _historyRouter, alarmConditionService: _alarmConditionService,
|
||||||
|
scriptedAlarmEngine: _scriptedAlarmEngine);
|
||||||
await _application.Start(_server).ConfigureAwait(false);
|
await _application.Start(_server).ConfigureAwait(false);
|
||||||
|
|
||||||
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.History;
|
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
@@ -35,6 +36,9 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
// dispatch — identical to pre-Phase-7 behaviour.
|
// dispatch — identical to pre-Phase-7 behaviour.
|
||||||
private readonly IReadable? _virtualReadable;
|
private readonly IReadable? _virtualReadable;
|
||||||
private readonly IReadable? _scriptedAlarmReadable;
|
private readonly IReadable? _scriptedAlarmReadable;
|
||||||
|
// Task #24 — Gap 1. Passed through to every DriverNodeManager so OPC UA Part 9
|
||||||
|
// Acknowledge/Confirm method calls on scripted alarm condition nodes reach the engine.
|
||||||
|
private readonly ScriptedAlarmEngine? _scriptedAlarmEngine;
|
||||||
|
|
||||||
// PR 1+2.W — server-level singletons shared across every DriverNodeManager.
|
// PR 1+2.W — server-level singletons shared across every DriverNodeManager.
|
||||||
// Null when the deployment hasn't opted into the new server-side history routing /
|
// Null when the deployment hasn't opted into the new server-side history routing /
|
||||||
@@ -68,7 +72,8 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
IReadable? scriptedAlarmReadable = null,
|
IReadable? scriptedAlarmReadable = null,
|
||||||
IReadOnlyList<string>? anonymousRoles = null,
|
IReadOnlyList<string>? anonymousRoles = null,
|
||||||
IHistoryRouter? historyRouter = null,
|
IHistoryRouter? historyRouter = null,
|
||||||
AlarmConditionService? alarmConditionService = null)
|
AlarmConditionService? alarmConditionService = null,
|
||||||
|
ScriptedAlarmEngine? scriptedAlarmEngine = null)
|
||||||
{
|
{
|
||||||
_driverHost = driverHost;
|
_driverHost = driverHost;
|
||||||
_authenticator = authenticator;
|
_authenticator = authenticator;
|
||||||
@@ -82,6 +87,7 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
_anonymousRoles = anonymousRoles ?? [];
|
_anonymousRoles = anonymousRoles ?? [];
|
||||||
_historyRouter = historyRouter;
|
_historyRouter = historyRouter;
|
||||||
_alarmConditionService = alarmConditionService;
|
_alarmConditionService = alarmConditionService;
|
||||||
|
_scriptedAlarmEngine = scriptedAlarmEngine;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +122,8 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
|
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
|
||||||
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
||||||
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
|
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
|
||||||
historyRouter: _historyRouter, alarmService: _alarmConditionService);
|
historyRouter: _historyRouter, alarmService: _alarmConditionService,
|
||||||
|
scriptedAlarmEngine: _scriptedAlarmEngine);
|
||||||
|
|
||||||
// The router stays empty after PR 1+2.W — DriverNodeManager's internal
|
// The router stays empty after PR 1+2.W — DriverNodeManager's internal
|
||||||
// LegacyDriverHistoryAdapter handles every driver that still implements
|
// LegacyDriverHistoryAdapter handles every driver that still implements
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ public sealed class OpcUaServerService(
|
|||||||
// — late binding after server start is rejected with InvalidOperationException.
|
// — late binding after server start is rejected with InvalidOperationException.
|
||||||
// No-op when the generation has no virtual tags or scripted alarms.
|
// No-op when the generation has no virtual tags or scripted alarms.
|
||||||
var phase7 = await phase7Composer.PrepareAsync(gen, stoppingToken);
|
var phase7 = await phase7Composer.PrepareAsync(gen, stoppingToken);
|
||||||
applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable);
|
applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable,
|
||||||
|
phase7.AlarmEngine);
|
||||||
|
|
||||||
// Phase 6.2 Stream C wiring — build the AuthorizationGate + NodeScopeResolver
|
// Phase 6.2 Stream C wiring — build the AuthorizationGate + NodeScopeResolver
|
||||||
// from the published generation's NodeAcl rows and the populated equipment
|
// from the published generation's NodeAcl rows and the populated equipment
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||||
@@ -42,6 +43,7 @@ public sealed class Phase7Composer : IAsyncDisposable
|
|||||||
private readonly DriverEquipmentContentRegistry _equipmentRegistry;
|
private readonly DriverEquipmentContentRegistry _equipmentRegistry;
|
||||||
private readonly IAlarmHistorianSink _historianSink;
|
private readonly IAlarmHistorianSink _historianSink;
|
||||||
private readonly IAlarmHistorianWriter? _injectedWriter;
|
private readonly IAlarmHistorianWriter? _injectedWriter;
|
||||||
|
private readonly IHistoryRouter? _historyRouter;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly Serilog.ILogger _scriptLogger;
|
private readonly Serilog.ILogger _scriptLogger;
|
||||||
private readonly ILogger<Phase7Composer> _logger;
|
private readonly ILogger<Phase7Composer> _logger;
|
||||||
@@ -61,13 +63,15 @@ public sealed class Phase7Composer : IAsyncDisposable
|
|||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
Serilog.ILogger scriptLogger,
|
Serilog.ILogger scriptLogger,
|
||||||
ILogger<Phase7Composer> logger,
|
ILogger<Phase7Composer> logger,
|
||||||
IAlarmHistorianWriter? injectedWriter = null)
|
IAlarmHistorianWriter? injectedWriter = null,
|
||||||
|
IHistoryRouter? historyRouter = null)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||||
_driverHost = driverHost ?? throw new ArgumentNullException(nameof(driverHost));
|
_driverHost = driverHost ?? throw new ArgumentNullException(nameof(driverHost));
|
||||||
_equipmentRegistry = equipmentRegistry ?? throw new ArgumentNullException(nameof(equipmentRegistry));
|
_equipmentRegistry = equipmentRegistry ?? throw new ArgumentNullException(nameof(equipmentRegistry));
|
||||||
_historianSink = historianSink ?? throw new ArgumentNullException(nameof(historianSink));
|
_historianSink = historianSink ?? throw new ArgumentNullException(nameof(historianSink));
|
||||||
_injectedWriter = injectedWriter;
|
_injectedWriter = injectedWriter;
|
||||||
|
_historyRouter = historyRouter;
|
||||||
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||||
_scriptLogger = scriptLogger ?? throw new ArgumentNullException(nameof(scriptLogger));
|
_scriptLogger = scriptLogger ?? throw new ArgumentNullException(nameof(scriptLogger));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
@@ -117,7 +121,8 @@ public sealed class Phase7Composer : IAsyncDisposable
|
|||||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||||
historianSink: historianSink,
|
historianSink: historianSink,
|
||||||
rootScriptLogger: _scriptLogger,
|
rootScriptLogger: _scriptLogger,
|
||||||
loggerFactory: _loggerFactory);
|
loggerFactory: _loggerFactory,
|
||||||
|
historyRouter: _historyRouter);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Phase 7: composed engines from generation {Gen} — {Vt} virtual tag(s), {Al} scripted alarm(s), {Sc} script(s)",
|
"Phase 7: composed engines from generation {Gen} — {Vt} virtual tag(s), {Al} scripted alarm(s), {Sc} script(s)",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||||
|
|
||||||
@@ -32,6 +33,14 @@ namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class Phase7EngineComposer
|
public static class Phase7EngineComposer
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Prefix used when registering the virtual-tag ring-buffer history source in
|
||||||
|
/// <see cref="IHistoryRouter"/>. All virtual-tag UNS paths are prefixed with this
|
||||||
|
/// string so the router resolves reads to the <see cref="RingBufferHistoryWriter"/>
|
||||||
|
/// rather than a driver-owned historian.
|
||||||
|
/// </summary>
|
||||||
|
public const string VirtualTagHistoryPrefix = "virtual:";
|
||||||
|
|
||||||
public static Phase7ComposedSources Compose(
|
public static Phase7ComposedSources Compose(
|
||||||
IReadOnlyList<Script> scripts,
|
IReadOnlyList<Script> scripts,
|
||||||
IReadOnlyList<VirtualTag> virtualTags,
|
IReadOnlyList<VirtualTag> virtualTags,
|
||||||
@@ -40,7 +49,8 @@ public static class Phase7EngineComposer
|
|||||||
IAlarmStateStore alarmStateStore,
|
IAlarmStateStore alarmStateStore,
|
||||||
IAlarmHistorianSink historianSink,
|
IAlarmHistorianSink historianSink,
|
||||||
Serilog.ILogger rootScriptLogger,
|
Serilog.ILogger rootScriptLogger,
|
||||||
ILoggerFactory loggerFactory)
|
ILoggerFactory loggerFactory,
|
||||||
|
IHistoryRouter? historyRouter = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(scripts);
|
ArgumentNullException.ThrowIfNull(scripts);
|
||||||
ArgumentNullException.ThrowIfNull(virtualTags);
|
ArgumentNullException.ThrowIfNull(virtualTags);
|
||||||
@@ -64,16 +74,47 @@ public static class Phase7EngineComposer
|
|||||||
// Engines take Serilog.ILogger — each engine gets its own so rolling-file emissions
|
// Engines take Serilog.ILogger — each engine gets its own so rolling-file emissions
|
||||||
// stay keyed to the right source in the scripts-*.log.
|
// stay keyed to the right source in the scripts-*.log.
|
||||||
VirtualTagSource? vtSource = null;
|
VirtualTagSource? vtSource = null;
|
||||||
|
RingBufferHistoryWriter? ringWriter = null;
|
||||||
if (virtualTags.Count > 0)
|
if (virtualTags.Count > 0)
|
||||||
{
|
{
|
||||||
var vtDefs = ProjectVirtualTags(virtualTags, scriptById).ToList();
|
var vtDefs = ProjectVirtualTags(virtualTags, scriptById).ToList();
|
||||||
var vtEngine = new VirtualTagEngine(upstream, scriptLoggerFactory, rootScriptLogger);
|
|
||||||
|
// Gap 5 closure (task #28): wire a real IHistoryWriter when any tag has
|
||||||
|
// Historize=true. RingBufferHistoryWriter is a bounded in-process ring buffer
|
||||||
|
// that also implements IHistorianDataSource so OPC UA HistoryRead on virtual
|
||||||
|
// nodes resolves here. Register with the IHistoryRouter under a dedicated
|
||||||
|
// prefix so the DriverNodeManager's history dispatch finds it.
|
||||||
|
IHistoryWriter historyWriter = NullHistoryWriter.Instance;
|
||||||
|
var hasHistorize = vtDefs.Any(d => d.Historize);
|
||||||
|
if (hasHistorize)
|
||||||
|
{
|
||||||
|
ringWriter = new RingBufferHistoryWriter();
|
||||||
|
historyWriter = ringWriter;
|
||||||
|
disposables.Add(ringWriter);
|
||||||
|
|
||||||
|
if (historyRouter is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
historyRouter.Register(VirtualTagHistoryPrefix, ringWriter);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Already registered (e.g. engine reload). Leave the existing entry —
|
||||||
|
// both registrations refer to the same in-process buffer type.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var vtEngine = new VirtualTagEngine(upstream, scriptLoggerFactory, rootScriptLogger,
|
||||||
|
historyWriter: historyWriter);
|
||||||
vtEngine.Load(vtDefs);
|
vtEngine.Load(vtDefs);
|
||||||
vtSource = new VirtualTagSource(vtEngine);
|
vtSource = new VirtualTagSource(vtEngine);
|
||||||
disposables.Add(vtEngine);
|
disposables.Add(vtEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
IReadable? alarmReadable = null;
|
IReadable? alarmReadable = null;
|
||||||
|
ScriptedAlarmEngine? composedAlarmEngine = null;
|
||||||
if (scriptedAlarms.Count > 0)
|
if (scriptedAlarms.Count > 0)
|
||||||
{
|
{
|
||||||
var alarmDefs = ProjectScriptedAlarms(scriptedAlarms, scriptById).ToList();
|
var alarmDefs = ProjectScriptedAlarms(scriptedAlarms, scriptById).ToList();
|
||||||
@@ -89,11 +130,12 @@ public static class Phase7EngineComposer
|
|||||||
// instead of BadNotFound. ScriptedAlarmSource stays registered as IAlarmSource
|
// instead of BadNotFound. ScriptedAlarmSource stays registered as IAlarmSource
|
||||||
// for the event stream; the IReadable is a separate adapter over the same engine.
|
// for the event stream; the IReadable is a separate adapter over the same engine.
|
||||||
alarmReadable = new ScriptedAlarmReadable(alarmEngine);
|
alarmReadable = new ScriptedAlarmReadable(alarmEngine);
|
||||||
|
composedAlarmEngine = alarmEngine;
|
||||||
disposables.Add(alarmEngine);
|
disposables.Add(alarmEngine);
|
||||||
disposables.Add(alarmSource);
|
disposables.Add(alarmSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Phase7ComposedSources(vtSource, alarmReadable, disposables);
|
return new Phase7ComposedSources(vtSource, alarmReadable, disposables, composedAlarmEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static IEnumerable<VirtualTagDefinition> ProjectVirtualTags(
|
internal static IEnumerable<VirtualTagDefinition> ProjectVirtualTags(
|
||||||
@@ -192,10 +234,17 @@ public static class Phase7EngineComposer
|
|||||||
/// <param name="VirtualReadable">Non-null when virtual tags were composed; pass to <c>OpcUaApplicationHost.virtualReadable</c>.</param>
|
/// <param name="VirtualReadable">Non-null when virtual tags were composed; pass to <c>OpcUaApplicationHost.virtualReadable</c>.</param>
|
||||||
/// <param name="ScriptedAlarmReadable">Non-null when scripted alarms were composed; pass to <c>OpcUaApplicationHost.scriptedAlarmReadable</c>.</param>
|
/// <param name="ScriptedAlarmReadable">Non-null when scripted alarms were composed; pass to <c>OpcUaApplicationHost.scriptedAlarmReadable</c>.</param>
|
||||||
/// <param name="Disposables">Engine + source instances the caller owns. Dispose on shutdown.</param>
|
/// <param name="Disposables">Engine + source instances the caller owns. Dispose on shutdown.</param>
|
||||||
|
/// <param name="AlarmEngine">
|
||||||
|
/// The <see cref="ScriptedAlarmEngine"/> instance, non-null when scripted alarms were
|
||||||
|
/// composed. Passed to <c>OpcUaApplicationHost</c> so <c>DriverNodeManager</c> can
|
||||||
|
/// route OPC UA Part 9 Acknowledge / Confirm method invocations directly to the engine
|
||||||
|
/// with the authenticated principal (task #24 — Gap 1 of phase-7-status.md).
|
||||||
|
/// </param>
|
||||||
public sealed record Phase7ComposedSources(
|
public sealed record Phase7ComposedSources(
|
||||||
IReadable? VirtualReadable,
|
IReadable? VirtualReadable,
|
||||||
IReadable? ScriptedAlarmReadable,
|
IReadable? ScriptedAlarmReadable,
|
||||||
IReadOnlyList<IDisposable> Disposables)
|
IReadOnlyList<IDisposable> Disposables,
|
||||||
|
ScriptedAlarmEngine? AlarmEngine = null)
|
||||||
{
|
{
|
||||||
public static readonly Phase7ComposedSources Empty =
|
public static readonly Phase7ComposedSources Empty =
|
||||||
new(null, null, Array.Empty<IDisposable>());
|
new(null, null, Array.Empty<IDisposable>());
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production <see cref="IHistoryWriter"/> for virtual-tag evaluations (Gap 5 closure,
|
||||||
|
/// task #28). Every evaluation result from a <c>Historize=true</c> virtual tag is
|
||||||
|
/// appended to an in-process per-tag ring buffer. The same instance also implements
|
||||||
|
/// <see cref="IHistorianDataSource"/> so it can be registered on the server-level
|
||||||
|
/// <see cref="ZB.MOM.WW.OtOpcUa.Server.History.IHistoryRouter"/> — OPC UA HistoryRead
|
||||||
|
/// requests for virtual-tag nodes then resolve here.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Design rationale:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// No new external process or DB is needed: the ring buffer lives in-process
|
||||||
|
/// and survives across evaluation cycles for the lifetime of the server.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="Record"/> is synchronous and O(1) — it never blocks the
|
||||||
|
/// evaluation pipeline. Per <see cref="IHistoryWriter"/> contract the caller
|
||||||
|
/// treats it as fire-and-forget.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// The buffer is bounded by <see cref="MaxSamplesPerTag"/> (default 1 000).
|
||||||
|
/// Older samples are evicted silently when the tag writes past the limit, which
|
||||||
|
/// matches the "hot ring" semantics typical of in-process historian buffers:
|
||||||
|
/// the most-recent N seconds of data is always accessible for HistoryRead even
|
||||||
|
/// in the absence of a persistent historian.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// Thread safety: <see cref="Record"/> uses an interlocked write pointer so
|
||||||
|
/// concurrent evaluation callbacks (rare but possible during cascade + timer
|
||||||
|
/// races) don't corrupt the buffer.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="ReadRawAsync"/> supports the OPC UA HistoryRead service's raw-values
|
||||||
|
/// mode. Processed (aggregate), at-time, and event read modes return empty results
|
||||||
|
/// with no error — virtual tags are scalar real-time values, not time-series stored
|
||||||
|
/// at external resolution.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Lifecycle: <see cref="Dispose"/> clears every buffer. The caller
|
||||||
|
/// (<see cref="Phase7Composer.DisposeAsync"/>) disposes this instance after the
|
||||||
|
/// engine that writes to it has been disposed, so no further <see cref="Record"/>
|
||||||
|
/// calls can arrive after dispose.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class RingBufferHistoryWriter : IHistoryWriter, IHistorianDataSource
|
||||||
|
{
|
||||||
|
/// <summary>Maximum samples retained per tag path. Older samples are evicted when full.</summary>
|
||||||
|
public const int MaxSamplesPerTag = 1_000;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, TagRingBuffer> _buffers =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
private readonly int _capacity;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <param name="capacity">
|
||||||
|
/// Per-tag sample capacity. Tests inject a smaller value to keep fixtures
|
||||||
|
/// small; production uses the default <see cref="MaxSamplesPerTag"/>.
|
||||||
|
/// </param>
|
||||||
|
public RingBufferHistoryWriter(int capacity = MaxSamplesPerTag)
|
||||||
|
{
|
||||||
|
if (capacity < 1) throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be ≥ 1.");
|
||||||
|
_capacity = capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== IHistoryWriter =====
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records one evaluation result. Called by <see cref="VirtualTagEngine"/> on every
|
||||||
|
/// evaluation when <c>Historize=true</c>. O(1), never blocks.
|
||||||
|
/// </summary>
|
||||||
|
public void Record(string path, DataValueSnapshot value)
|
||||||
|
{
|
||||||
|
if (_disposed) return; // graceful shutdown — silently drop
|
||||||
|
|
||||||
|
var buffer = _buffers.GetOrAdd(path, _ => new TagRingBuffer(_capacity));
|
||||||
|
buffer.Write(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== IHistorianDataSource =====
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns samples in the ring buffer whose source timestamp falls within
|
||||||
|
/// [<paramref name="startUtc"/>, <paramref name="endUtc"/>), newest-first.
|
||||||
|
/// <paramref name="maxValuesPerNode"/> caps the result count.
|
||||||
|
/// </summary>
|
||||||
|
public Task<HistoryReadResult> ReadRawAsync(
|
||||||
|
string fullReference,
|
||||||
|
DateTime startUtc,
|
||||||
|
DateTime endUtc,
|
||||||
|
uint maxValuesPerNode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_buffers.TryGetValue(fullReference, out var buffer))
|
||||||
|
return Task.FromResult(new HistoryReadResult([], null));
|
||||||
|
|
||||||
|
var all = buffer.Snapshot();
|
||||||
|
var limit = (int)Math.Min(maxValuesPerNode, (uint)all.Length);
|
||||||
|
var result = new List<DataValueSnapshot>(limit);
|
||||||
|
|
||||||
|
foreach (var snap in all)
|
||||||
|
{
|
||||||
|
if (result.Count >= limit) break;
|
||||||
|
var ts = snap.SourceTimestampUtc ?? snap.ServerTimestampUtc;
|
||||||
|
if (ts >= startUtc && ts < endUtc)
|
||||||
|
result.Add(snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new HistoryReadResult(result, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Virtual tags do not carry aggregate history — returns an empty result rather than
|
||||||
|
/// failing so OPC UA clients that request processed history receive a graceful empty
|
||||||
|
/// response instead of a Bad status for the whole node.
|
||||||
|
/// </remarks>
|
||||||
|
public Task<HistoryReadResult> ReadProcessedAsync(
|
||||||
|
string fullReference,
|
||||||
|
DateTime startUtc,
|
||||||
|
DateTime endUtc,
|
||||||
|
TimeSpan interval,
|
||||||
|
HistoryAggregateType aggregate,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(new HistoryReadResult([], null));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<HistoryReadResult> ReadAtTimeAsync(
|
||||||
|
string fullReference,
|
||||||
|
IReadOnlyList<DateTime> timestampsUtc,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(new HistoryReadResult([], null));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||||
|
string? sourceName,
|
||||||
|
DateTime startUtc,
|
||||||
|
DateTime endUtc,
|
||||||
|
int maxEvents,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(new HistoricalEventsResult([], null));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public HistorianHealthSnapshot GetHealthSnapshot() => new(
|
||||||
|
TotalQueries: 0,
|
||||||
|
TotalSuccesses: 0,
|
||||||
|
TotalFailures: 0,
|
||||||
|
ConsecutiveFailures: 0,
|
||||||
|
LastSuccessTime: null,
|
||||||
|
LastFailureTime: null,
|
||||||
|
LastError: null,
|
||||||
|
ProcessConnectionOpen: true,
|
||||||
|
EventConnectionOpen: false,
|
||||||
|
ActiveProcessNode: null,
|
||||||
|
ActiveEventNode: null,
|
||||||
|
Nodes: []);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of distinct tag paths that have received at least one recorded sample.
|
||||||
|
/// Exposed for diagnostics and tests.
|
||||||
|
/// </summary>
|
||||||
|
public int TagCount => _buffers.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a snapshot of all samples currently in the buffer for <paramref name="path"/>,
|
||||||
|
/// or an empty array when the path has no recorded samples. Exposed for diagnostics.
|
||||||
|
/// </summary>
|
||||||
|
public DataValueSnapshot[] GetSnapshots(string path)
|
||||||
|
=> _buffers.TryGetValue(path, out var buf) ? buf.Snapshot() : [];
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_buffers.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Inner ring buffer =====
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bounded FIFO ring buffer with O(1) write. Reads return a snapshot (array copy)
|
||||||
|
/// of all current samples in insertion order, oldest first.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class TagRingBuffer
|
||||||
|
{
|
||||||
|
private readonly DataValueSnapshot?[] _slots;
|
||||||
|
private int _head; // next write position (wraps)
|
||||||
|
private int _count; // how many valid entries (≤ capacity)
|
||||||
|
|
||||||
|
public TagRingBuffer(int capacity)
|
||||||
|
{
|
||||||
|
_slots = new DataValueSnapshot[capacity];
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Write(DataValueSnapshot value)
|
||||||
|
{
|
||||||
|
lock (_slots)
|
||||||
|
{
|
||||||
|
_slots[_head] = value;
|
||||||
|
_head = (_head + 1) % _slots.Length;
|
||||||
|
if (_count < _slots.Length) _count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all current samples in insertion order (oldest → newest), as a
|
||||||
|
/// snapshot array. Thread-safe: takes the lock for the copy.
|
||||||
|
/// </summary>
|
||||||
|
public DataValueSnapshot[] Snapshot()
|
||||||
|
{
|
||||||
|
lock (_slots)
|
||||||
|
{
|
||||||
|
if (_count == 0) return [];
|
||||||
|
|
||||||
|
var result = new DataValueSnapshot[_count];
|
||||||
|
// The oldest entry is at (_head - _count + capacity) % capacity.
|
||||||
|
var start = (_head - _count + _slots.Length) % _slots.Length;
|
||||||
|
for (var i = 0; i < _count; i++)
|
||||||
|
{
|
||||||
|
result[i] = _slots[(start + i) % _slots.Length]!;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,9 +68,15 @@ public sealed class VirtualTagEngineTests
|
|||||||
engine.Read("B").Value.ShouldBe(11.0);
|
engine.Read("B").Value.ShouldBe(11.0);
|
||||||
engine.Read("C").Value.ShouldBe(22.0);
|
engine.Read("C").Value.ShouldBe(22.0);
|
||||||
|
|
||||||
// Change upstream — cascade should recompute B (11→15.0) then C (30.0)
|
// Change upstream — cascade should recompute B (11→15.0) then C (30.0).
|
||||||
|
// Both B and C are updated in the same CascadeAsync call (topological order:
|
||||||
|
// B then C), but we must wait for each independently: the WaitForConditionAsync
|
||||||
|
// on B returns as soon as _valueCache["B"] is set (before the semaphore is
|
||||||
|
// released for C's evaluation), so asserting C immediately after the B-wait
|
||||||
|
// races against C's still-in-progress evaluation. Wait for C explicitly.
|
||||||
up.Push("A", 5.0);
|
up.Push("A", 5.0);
|
||||||
await WaitForConditionAsync(() => Equals(engine.Read("B").Value, 15.0));
|
await WaitForConditionAsync(() => Equals(engine.Read("B").Value, 15.0));
|
||||||
|
await WaitForConditionAsync(() => Equals(engine.Read("C").Value, 30.0));
|
||||||
engine.Read("B").Value.ShouldBe(15.0);
|
engine.Read("B").Value.ShouldBe(15.0);
|
||||||
engine.Read("C").Value.ShouldBe(30.0);
|
engine.Read("C").Value.ShouldBe(30.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,11 @@ public sealed class EventPumpBoundedChannelTests
|
|||||||
{
|
{
|
||||||
if (instr.Meter.Name == EventPump.MeterName) l.EnableMeasurementEvents(instr);
|
if (instr.Meter.Name == EventPump.MeterName) l.EnableMeasurementEvents(instr);
|
||||||
};
|
};
|
||||||
|
// The callback fires on the thread that calls Counter.Add() — that is the
|
||||||
|
// RunAsync background Task. Use lock(captured) everywhere to avoid torn reads.
|
||||||
listener.SetMeasurementEventCallback<long>((instr, _, tags, _) =>
|
listener.SetMeasurementEventCallback<long>((instr, _, tags, _) =>
|
||||||
{
|
{
|
||||||
captured.Add((instr.Name, tags.ToArray()));
|
lock (captured) { captured.Add((instr.Name, tags.ToArray())); }
|
||||||
});
|
});
|
||||||
listener.Start();
|
listener.Start();
|
||||||
|
|
||||||
@@ -101,17 +103,40 @@ public sealed class EventPumpBoundedChannelTests
|
|||||||
{
|
{
|
||||||
pump.Start();
|
pump.Start();
|
||||||
await subscriber.EmitAsync(7, 42.0);
|
await subscriber.EmitAsync(7, 42.0);
|
||||||
await Task.Delay(100);
|
|
||||||
listener.RecordObservableInstruments();
|
// Poll until at least one galaxy.events.received measurement tagged
|
||||||
|
// galaxy.client=Driver-X lands in the listener, rather than using a
|
||||||
|
// fixed delay that races under parallel test load on a busy box.
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
bool found = false;
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
listener.RecordObservableInstruments();
|
||||||
|
bool hasMatch;
|
||||||
|
lock (captured)
|
||||||
|
{
|
||||||
|
hasMatch = captured.Any(c =>
|
||||||
|
c.Instrument == "galaxy.events.received" &&
|
||||||
|
c.Tags.Any(t => t.Key == "galaxy.client" &&
|
||||||
|
string.Equals((string?)t.Value, "Driver-X", StringComparison.Ordinal)));
|
||||||
|
}
|
||||||
|
if (hasMatch) { found = true; break; }
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
_ = found; // assertion happens below after dispose
|
||||||
}
|
}
|
||||||
|
|
||||||
// The static Meter is shared across all EventPump instances in the test
|
// The static Meter is shared across all EventPump instances in the test
|
||||||
// assembly; xUnit may run other pump tests in parallel and their
|
// assembly; xUnit may run other pump tests in parallel and their
|
||||||
// measurements land on the same listener. Filter to our pump's tag value.
|
// measurements land on the same listener. Filter to our pump's tag value.
|
||||||
var ours = captured
|
List<(string Instrument, KeyValuePair<string, object?>[] Tags)> ours;
|
||||||
.Where(c => c.Tags.Any(t => t.Key == "galaxy.client"
|
lock (captured)
|
||||||
&& string.Equals((string?)t.Value, "Driver-X", StringComparison.Ordinal)))
|
{
|
||||||
.ToList();
|
ours = captured
|
||||||
|
.Where(c => c.Tags.Any(t => t.Key == "galaxy.client"
|
||||||
|
&& string.Equals((string?)t.Value, "Driver-X", StringComparison.Ordinal)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
ours.ShouldNotBeEmpty(
|
ours.ShouldNotBeEmpty(
|
||||||
"at least one measurement from this test's pump must carry galaxy.client=Driver-X");
|
"at least one measurement from this test's pump must carry galaxy.client=Driver-X");
|
||||||
|
|||||||
198
tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/ScriptLogHubTests.cs
Normal file
198
tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/ScriptLogHubTests.cs
Normal 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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Serilog;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task #28 — Gap 5 closure: verifies that <see cref="RingBufferHistoryWriter"/>
|
||||||
|
/// correctly records virtual-tag evaluation results and returns them via the
|
||||||
|
/// <see cref="IHistorianDataSource"/> read interface; and that
|
||||||
|
/// <see cref="Phase7EngineComposer.Compose"/> wires the writer and registers it
|
||||||
|
/// with an <see cref="IHistoryRouter"/> when <c>Historize=true</c> tags are present.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class RingBufferHistoryWriterTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
private static readonly DateTime T1 = T0.AddSeconds(1);
|
||||||
|
private static readonly DateTime T2 = T0.AddSeconds(2);
|
||||||
|
private static readonly DateTime T3 = T0.AddSeconds(3);
|
||||||
|
|
||||||
|
private static DataValueSnapshot Snap(double value, DateTime ts) =>
|
||||||
|
new(value, 0u, ts, ts);
|
||||||
|
|
||||||
|
// ===== RingBufferHistoryWriter unit tests =====
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Record_stores_sample_retrievable_via_GetSnapshots()
|
||||||
|
{
|
||||||
|
using var writer = new RingBufferHistoryWriter();
|
||||||
|
writer.Record("/area/line/eq/Tag1", Snap(42.0, T0));
|
||||||
|
|
||||||
|
var snaps = writer.GetSnapshots("/area/line/eq/Tag1");
|
||||||
|
snaps.Length.ShouldBe(1);
|
||||||
|
snaps[0].Value.ShouldBe(42.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Record_multiple_samples_preserves_insertion_order()
|
||||||
|
{
|
||||||
|
using var writer = new RingBufferHistoryWriter();
|
||||||
|
writer.Record("/t", Snap(1.0, T0));
|
||||||
|
writer.Record("/t", Snap(2.0, T1));
|
||||||
|
writer.Record("/t", Snap(3.0, T2));
|
||||||
|
|
||||||
|
var snaps = writer.GetSnapshots("/t");
|
||||||
|
snaps.Length.ShouldBe(3);
|
||||||
|
snaps[0].Value.ShouldBe(1.0);
|
||||||
|
snaps[1].Value.ShouldBe(2.0);
|
||||||
|
snaps[2].Value.ShouldBe(3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Record_evicts_oldest_when_capacity_exceeded()
|
||||||
|
{
|
||||||
|
using var writer = new RingBufferHistoryWriter(capacity: 3);
|
||||||
|
writer.Record("/t", Snap(1.0, T0));
|
||||||
|
writer.Record("/t", Snap(2.0, T1));
|
||||||
|
writer.Record("/t", Snap(3.0, T2));
|
||||||
|
writer.Record("/t", Snap(4.0, T3)); // evicts 1.0
|
||||||
|
|
||||||
|
var snaps = writer.GetSnapshots("/t");
|
||||||
|
snaps.Length.ShouldBe(3);
|
||||||
|
snaps[0].Value.ShouldBe(2.0, "oldest evicted");
|
||||||
|
snaps[2].Value.ShouldBe(4.0, "newest present");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Record_maintains_separate_buffers_per_tag_path()
|
||||||
|
{
|
||||||
|
using var writer = new RingBufferHistoryWriter();
|
||||||
|
writer.Record("/area/eq/TagA", Snap(10.0, T0));
|
||||||
|
writer.Record("/area/eq/TagB", Snap(20.0, T0));
|
||||||
|
|
||||||
|
writer.GetSnapshots("/area/eq/TagA").Single().Value.ShouldBe(10.0);
|
||||||
|
writer.GetSnapshots("/area/eq/TagB").Single().Value.ShouldBe(20.0);
|
||||||
|
writer.TagCount.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshots_returns_empty_for_unknown_path()
|
||||||
|
{
|
||||||
|
using var writer = new RingBufferHistoryWriter();
|
||||||
|
writer.GetSnapshots("/not/a/path").ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_clears_buffers_and_subsequent_Record_is_silently_ignored()
|
||||||
|
{
|
||||||
|
var writer = new RingBufferHistoryWriter();
|
||||||
|
writer.Record("/t", Snap(1.0, T0));
|
||||||
|
writer.Dispose();
|
||||||
|
|
||||||
|
// After dispose, Record must silently drop (no exception).
|
||||||
|
Should.NotThrow(() => writer.Record("/t", Snap(2.0, T1)));
|
||||||
|
// GetSnapshots post-dispose returns empty (buffers cleared).
|
||||||
|
writer.GetSnapshots("/t").ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== IHistorianDataSource.ReadRawAsync tests =====
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadRawAsync_returns_empty_for_unknown_path()
|
||||||
|
{
|
||||||
|
using var writer = new RingBufferHistoryWriter();
|
||||||
|
var result = await writer.ReadRawAsync("notexists", T0, T3, 100, default);
|
||||||
|
result.Samples.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadRawAsync_returns_samples_in_time_window()
|
||||||
|
{
|
||||||
|
using var writer = new RingBufferHistoryWriter();
|
||||||
|
writer.Record("/t", Snap(1.0, T0));
|
||||||
|
writer.Record("/t", Snap(2.0, T1));
|
||||||
|
writer.Record("/t", Snap(3.0, T2));
|
||||||
|
|
||||||
|
// Window [T0, T2) — T2 excluded (half-open interval).
|
||||||
|
var result = await writer.ReadRawAsync("/t", T0, T2, 100, default);
|
||||||
|
result.Samples.Count.ShouldBe(2);
|
||||||
|
result.Samples[0].Value.ShouldBe(1.0);
|
||||||
|
result.Samples[1].Value.ShouldBe(2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadRawAsync_respects_maxValuesPerNode_cap()
|
||||||
|
{
|
||||||
|
using var writer = new RingBufferHistoryWriter();
|
||||||
|
writer.Record("/t", Snap(1.0, T0));
|
||||||
|
writer.Record("/t", Snap(2.0, T1));
|
||||||
|
writer.Record("/t", Snap(3.0, T2));
|
||||||
|
|
||||||
|
var result = await writer.ReadRawAsync("/t", T0, T3, maxValuesPerNode: 2, default);
|
||||||
|
result.Samples.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadProcessedAsync_returns_empty_result()
|
||||||
|
{
|
||||||
|
using var writer = new RingBufferHistoryWriter();
|
||||||
|
writer.Record("/t", Snap(1.0, T0));
|
||||||
|
var result = await writer.ReadProcessedAsync("/t", T0, T3, TimeSpan.FromSeconds(1),
|
||||||
|
HistoryAggregateType.Average, default);
|
||||||
|
result.Samples.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAtTimeAsync_returns_empty_result()
|
||||||
|
{
|
||||||
|
using var writer = new RingBufferHistoryWriter();
|
||||||
|
writer.Record("/t", Snap(1.0, T0));
|
||||||
|
var result = await writer.ReadAtTimeAsync("/t", [T0, T1], default);
|
||||||
|
result.Samples.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadEventsAsync_returns_empty_result()
|
||||||
|
{
|
||||||
|
using var writer = new RingBufferHistoryWriter();
|
||||||
|
var result = await writer.ReadEventsAsync(null, T0, T3, 100, default);
|
||||||
|
result.Events.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHealthSnapshot_returns_connected_non_null_snapshot()
|
||||||
|
{
|
||||||
|
using var writer = new RingBufferHistoryWriter();
|
||||||
|
var health = writer.GetHealthSnapshot();
|
||||||
|
health.ShouldNotBeNull();
|
||||||
|
health.ProcessConnectionOpen.ShouldBeTrue("ring buffer is always available in-process");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Phase7EngineComposer wiring tests =====
|
||||||
|
|
||||||
|
private static Script ScriptRow(string id, string source) => new()
|
||||||
|
{
|
||||||
|
ScriptRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static VirtualTag VtRow(string id, string scriptId, bool historize = false) => new()
|
||||||
|
{
|
||||||
|
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
VirtualTagId = id, EquipmentId = "eq-1", Name = id,
|
||||||
|
DataType = "Float32", ScriptId = scriptId,
|
||||||
|
Historize = historize,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ScriptedAlarm AlarmRow(string id, string scriptId) => new()
|
||||||
|
{
|
||||||
|
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
ScriptedAlarmId = id, EquipmentId = "eq-1", Name = id,
|
||||||
|
AlarmType = "LimitAlarm", Severity = 500,
|
||||||
|
MessageTemplate = "x", PredicateScriptId = scriptId,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compose_without_Historize_uses_NullHistoryWriter_and_skips_router_registration()
|
||||||
|
{
|
||||||
|
using var router = new HistoryRouter();
|
||||||
|
var scripts = new[] { ScriptRow("s1", "return 1;") };
|
||||||
|
var vtags = new[] { VtRow("vt-1", "s1", historize: false) };
|
||||||
|
|
||||||
|
var result = Phase7EngineComposer.Compose(
|
||||||
|
scripts, vtags, [],
|
||||||
|
upstream: new CachedTagUpstreamSource(),
|
||||||
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||||
|
historianSink: NullAlarmHistorianSink.Instance,
|
||||||
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||||
|
loggerFactory: NullLoggerFactory.Instance,
|
||||||
|
historyRouter: router);
|
||||||
|
|
||||||
|
// Router should not have a "virtual:" prefix entry when no Historize=true tags.
|
||||||
|
router.Resolve(Phase7EngineComposer.VirtualTagHistoryPrefix + "vt-1").ShouldBeNull();
|
||||||
|
result.VirtualReadable.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compose_with_Historize_true_registers_RingBufferHistoryWriter_in_router()
|
||||||
|
{
|
||||||
|
using var router = new HistoryRouter();
|
||||||
|
var scripts = new[] { ScriptRow("s1", "return 1.0f;") };
|
||||||
|
var vtags = new[] { VtRow("vt-hist", "s1", historize: true) };
|
||||||
|
|
||||||
|
var result = Phase7EngineComposer.Compose(
|
||||||
|
scripts, vtags, [],
|
||||||
|
upstream: new CachedTagUpstreamSource(),
|
||||||
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||||
|
historianSink: NullAlarmHistorianSink.Instance,
|
||||||
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||||
|
loggerFactory: NullLoggerFactory.Instance,
|
||||||
|
historyRouter: router);
|
||||||
|
|
||||||
|
// The "virtual:" prefix must resolve to a RingBufferHistoryWriter instance.
|
||||||
|
var source = router.Resolve(Phase7EngineComposer.VirtualTagHistoryPrefix + "vt-hist");
|
||||||
|
source.ShouldNotBeNull("router should have the ring-buffer source registered under 'virtual:' prefix");
|
||||||
|
source.ShouldBeOfType<RingBufferHistoryWriter>();
|
||||||
|
|
||||||
|
result.VirtualReadable.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compose_with_Historize_true_but_no_router_does_not_throw()
|
||||||
|
{
|
||||||
|
var scripts = new[] { ScriptRow("s1", "return 1.0f;") };
|
||||||
|
var vtags = new[] { VtRow("vt-hist", "s1", historize: true) };
|
||||||
|
|
||||||
|
// historyRouter = null — should still work, just no registration.
|
||||||
|
Should.NotThrow(() => Phase7EngineComposer.Compose(
|
||||||
|
scripts, vtags, [],
|
||||||
|
upstream: new CachedTagUpstreamSource(),
|
||||||
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||||
|
historianSink: NullAlarmHistorianSink.Instance,
|
||||||
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||||
|
loggerFactory: NullLoggerFactory.Instance,
|
||||||
|
historyRouter: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compose_with_Historize_true_router_already_registered_does_not_throw()
|
||||||
|
{
|
||||||
|
// Simulate a reload scenario where the prefix is already registered.
|
||||||
|
using var router = new HistoryRouter();
|
||||||
|
using var priorWriter = new RingBufferHistoryWriter();
|
||||||
|
router.Register(Phase7EngineComposer.VirtualTagHistoryPrefix, priorWriter);
|
||||||
|
|
||||||
|
var scripts = new[] { ScriptRow("s1", "return 1.0f;") };
|
||||||
|
var vtags = new[] { VtRow("vt-hist", "s1", historize: true) };
|
||||||
|
|
||||||
|
// Second compose call — should tolerate the duplicate without throwing.
|
||||||
|
Should.NotThrow(() => Phase7EngineComposer.Compose(
|
||||||
|
scripts, vtags, [],
|
||||||
|
upstream: new CachedTagUpstreamSource(),
|
||||||
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||||
|
historianSink: NullAlarmHistorianSink.Instance,
|
||||||
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||||
|
loggerFactory: NullLoggerFactory.Instance,
|
||||||
|
historyRouter: router));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compose_RingBufferHistoryWriter_is_in_disposables_list()
|
||||||
|
{
|
||||||
|
using var router = new HistoryRouter();
|
||||||
|
var scripts = new[] { ScriptRow("s1", "return 1.0f;") };
|
||||||
|
var vtags = new[] { VtRow("vt-hist", "s1", historize: true) };
|
||||||
|
|
||||||
|
var result = Phase7EngineComposer.Compose(
|
||||||
|
scripts, vtags, [],
|
||||||
|
upstream: new CachedTagUpstreamSource(),
|
||||||
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||||
|
historianSink: NullAlarmHistorianSink.Instance,
|
||||||
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||||
|
loggerFactory: NullLoggerFactory.Instance,
|
||||||
|
historyRouter: router);
|
||||||
|
|
||||||
|
// The RingBufferHistoryWriter must be tracked in Disposables so Phase7Composer.DisposeAsync
|
||||||
|
// clears the ring buffer on shutdown.
|
||||||
|
result.Disposables.ShouldContain(d => d is RingBufferHistoryWriter);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Serilog;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||||
|
using CoreAlarmConditionState = ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.AlarmConditionState;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task #24 — Gap 1 of phase-7-status.md. Covers
|
||||||
|
/// <see cref="DriverNodeManager.RouteScriptedAlarmMethodCalls"/> which intercepts
|
||||||
|
/// OPC UA Part 9 Acknowledge / Confirm method invocations on scripted alarm condition
|
||||||
|
/// nodes and routes them to <see cref="ScriptedAlarmEngine"/>, and the
|
||||||
|
/// <see cref="Phase7ComposedSources.AlarmEngine"/> property added to expose the
|
||||||
|
/// engine through the composition chain.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ScriptedAlarmMethodRoutingTests
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase7ComposedSources — AlarmEngine property
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compose_ScriptedAlarm_rows_exposes_AlarmEngine()
|
||||||
|
{
|
||||||
|
var scripts = new[] { ScriptRow("scr-1", "return false;") };
|
||||||
|
var alarms = new[] { AlarmRow("al-1", "scr-1") };
|
||||||
|
|
||||||
|
var result = Phase7EngineComposer.Compose(
|
||||||
|
scripts, [], alarms,
|
||||||
|
upstream: new CachedTagUpstreamSource(),
|
||||||
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||||
|
historianSink: NullAlarmHistorianSink.Instance,
|
||||||
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||||
|
loggerFactory: NullLoggerFactory.Instance);
|
||||||
|
|
||||||
|
result.AlarmEngine.ShouldNotBeNull("engine is exposed so the server can route method calls");
|
||||||
|
result.ScriptedAlarmReadable.ShouldNotBeNull();
|
||||||
|
result.Disposables.Count.ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compose_empty_rows_AlarmEngine_is_null()
|
||||||
|
{
|
||||||
|
var result = Phase7EngineComposer.Compose(
|
||||||
|
scripts: [],
|
||||||
|
virtualTags: [],
|
||||||
|
scriptedAlarms: [],
|
||||||
|
upstream: new CachedTagUpstreamSource(),
|
||||||
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||||
|
historianSink: NullAlarmHistorianSink.Instance,
|
||||||
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||||
|
loggerFactory: NullLoggerFactory.Instance);
|
||||||
|
|
||||||
|
result.ShouldBeSameAs(Phase7ComposedSources.Empty);
|
||||||
|
result.AlarmEngine.ShouldBeNull("empty composition returns the Empty sentinel with all-null engines");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compose_VirtualTag_only_AlarmEngine_is_null()
|
||||||
|
{
|
||||||
|
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
|
||||||
|
var vtags = new[] { VtRow("vt-1", "scr-1") };
|
||||||
|
|
||||||
|
var result = Phase7EngineComposer.Compose(
|
||||||
|
scripts, vtags, [],
|
||||||
|
upstream: new CachedTagUpstreamSource(),
|
||||||
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||||
|
historianSink: NullAlarmHistorianSink.Instance,
|
||||||
|
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||||
|
loggerFactory: NullLoggerFactory.Instance);
|
||||||
|
|
||||||
|
result.AlarmEngine.ShouldBeNull("no scripted alarms → alarm engine is null");
|
||||||
|
result.VirtualReadable.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// RouteScriptedAlarmMethodCalls — pure-function dispatch kernel
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a loaded ScriptedAlarmEngine with the given alarm IDs.
|
||||||
|
/// All predicates return <c>false</c> so the alarm starts Inactive.
|
||||||
|
/// </summary>
|
||||||
|
private static ScriptedAlarmEngine BuildEngine(params string[] alarmIds)
|
||||||
|
{
|
||||||
|
var upstream = new CachedTagUpstreamSource();
|
||||||
|
var logger = new LoggerConfiguration().CreateLogger();
|
||||||
|
var factory = new ScriptLoggerFactory(logger);
|
||||||
|
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
|
||||||
|
var defs = alarmIds.Select(id => new ScriptedAlarmDefinition(
|
||||||
|
AlarmId: id,
|
||||||
|
EquipmentPath: "/eq",
|
||||||
|
AlarmName: id,
|
||||||
|
Kind: AlarmKind.LimitAlarm,
|
||||||
|
Severity: AlarmSeverity.Medium,
|
||||||
|
MessageTemplate: "msg",
|
||||||
|
PredicateScriptSource: "return false;")).ToList();
|
||||||
|
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a loaded ScriptedAlarmEngine where the named alarm starts Active
|
||||||
|
/// (predicate = return true) so subsequent Acknowledge tests have an
|
||||||
|
/// Unacknowledged state to advance.
|
||||||
|
/// </summary>
|
||||||
|
private static ScriptedAlarmEngine BuildActiveEngine(string alarmId)
|
||||||
|
{
|
||||||
|
var upstream = new CachedTagUpstreamSource();
|
||||||
|
var logger = new LoggerConfiguration().CreateLogger();
|
||||||
|
var factory = new ScriptLoggerFactory(logger);
|
||||||
|
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
|
||||||
|
var defs = new List<ScriptedAlarmDefinition>
|
||||||
|
{
|
||||||
|
new(AlarmId: alarmId,
|
||||||
|
EquipmentPath: "/eq",
|
||||||
|
AlarmName: alarmId,
|
||||||
|
Kind: AlarmKind.LimitAlarm,
|
||||||
|
Severity: AlarmSeverity.Medium,
|
||||||
|
MessageTemplate: "msg",
|
||||||
|
PredicateScriptSource: "return true;"),
|
||||||
|
};
|
||||||
|
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IUserIdentity? MakeIdentity(string? displayName)
|
||||||
|
=> displayName is null ? null : new NamedUserIdentity(displayName);
|
||||||
|
|
||||||
|
private static CallMethodRequest AcknowledgeRequest(string conditionNodeId, string? comment = null)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
ObjectId = new NodeId(conditionNodeId, 2),
|
||||||
|
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
|
||||||
|
InputArguments = new VariantCollection
|
||||||
|
{
|
||||||
|
new Variant(new byte[] { 1, 2, 3 }), // EventId (ByteString) — ignored
|
||||||
|
new Variant(new LocalizedText(comment ?? string.Empty)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private static CallMethodRequest ConfirmRequest(string conditionNodeId, string? comment = null)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
ObjectId = new NodeId(conditionNodeId, 2),
|
||||||
|
MethodId = MethodIds.AcknowledgeableConditionType_Confirm,
|
||||||
|
InputArguments = new VariantCollection
|
||||||
|
{
|
||||||
|
new Variant(new byte[] { 1, 2, 3 }), // EventId (ByteString) — ignored
|
||||||
|
new Variant(new LocalizedText(comment ?? string.Empty)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private static CallMethodRequest GenericRequest(string objectNodeId)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
ObjectId = new NodeId(objectNodeId, 2),
|
||||||
|
MethodId = new NodeId("driver-method", 2),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Dictionary<string, string> Index(params (string condId, string alarmId)[] entries)
|
||||||
|
=> entries.ToDictionary(e => e.condId, e => e.alarmId, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// ---- no-op paths -------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_index_entries_leaves_all_slots_untouched()
|
||||||
|
{
|
||||||
|
using var engine = BuildActiveEngine("al-1");
|
||||||
|
var calls = new List<CallMethodRequest>
|
||||||
|
{
|
||||||
|
AcknowledgeRequest("al-1.Condition"),
|
||||||
|
};
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { null! };
|
||||||
|
|
||||||
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||||
|
MakeIdentity("alice"), calls, results, errors, engine,
|
||||||
|
conditionIdToAlarmId: new Dictionary<string, string>());
|
||||||
|
|
||||||
|
errors[0].ShouldBeNull("no matching entry → slot left for base.Call");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Non_alarm_method_id_is_ignored()
|
||||||
|
{
|
||||||
|
using var engine = BuildEngine("al-1");
|
||||||
|
var calls = new List<CallMethodRequest>
|
||||||
|
{
|
||||||
|
GenericRequest("al-1.Condition"),
|
||||||
|
};
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { null! };
|
||||||
|
var index = Index(("al-1.Condition", "al-1"));
|
||||||
|
|
||||||
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||||
|
MakeIdentity("alice"), calls, results, errors, engine, index);
|
||||||
|
|
||||||
|
errors[0].ShouldBeNull("non-Acknowledge/Confirm methods pass through untouched");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Already_errored_slot_is_skipped()
|
||||||
|
{
|
||||||
|
using var engine = BuildActiveEngine("al-1");
|
||||||
|
var calls = new List<CallMethodRequest>
|
||||||
|
{
|
||||||
|
AcknowledgeRequest("al-1.Condition"),
|
||||||
|
};
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var priorError = new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||||
|
var errors = new List<ServiceResult> { priorError };
|
||||||
|
var index = Index(("al-1.Condition", "al-1"));
|
||||||
|
|
||||||
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||||
|
MakeIdentity("alice"), calls, results, errors, engine, index);
|
||||||
|
|
||||||
|
// Pre-populated bad error must not be overwritten.
|
||||||
|
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Acknowledge -------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Acknowledge_on_active_alarm_advances_engine_state()
|
||||||
|
{
|
||||||
|
using var engine = BuildActiveEngine("al-1");
|
||||||
|
|
||||||
|
// Sanity: alarm must start unacknowledged after activation.
|
||||||
|
engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
||||||
|
|
||||||
|
var calls = new List<CallMethodRequest> { AcknowledgeRequest("al-1.Condition", "looks ok") };
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { null! };
|
||||||
|
var index = Index(("al-1.Condition", "al-1"));
|
||||||
|
|
||||||
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||||
|
MakeIdentity("ops-user"), calls, results, errors, engine, index);
|
||||||
|
|
||||||
|
errors[0].ShouldNotBeNull();
|
||||||
|
ServiceResult.IsBad(errors[0]).ShouldBeFalse("Acknowledge succeeded");
|
||||||
|
engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||||
|
engine.GetState("al-1")!.LastAckUser.ShouldBe("ops-user");
|
||||||
|
engine.GetState("al-1")!.LastAckComment.ShouldBe("looks ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Acknowledge_uses_opcua_client_as_fallback_when_identity_is_null()
|
||||||
|
{
|
||||||
|
using var engine = BuildActiveEngine("al-1");
|
||||||
|
|
||||||
|
var calls = new List<CallMethodRequest> { AcknowledgeRequest("al-1.Condition") };
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { null! };
|
||||||
|
var index = Index(("al-1.Condition", "al-1"));
|
||||||
|
|
||||||
|
// Pass null identity (anonymous session).
|
||||||
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||||
|
userIdentity: null, calls, results, errors, engine, index);
|
||||||
|
|
||||||
|
engine.GetState("al-1")!.LastAckUser.ShouldBe("opcua-client");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Acknowledge_with_no_input_arguments_uses_null_comment()
|
||||||
|
{
|
||||||
|
using var engine = BuildActiveEngine("al-1");
|
||||||
|
// Build a request without InputArguments to simulate a client that omits the comment.
|
||||||
|
var requestNoArgs = new CallMethodRequest
|
||||||
|
{
|
||||||
|
ObjectId = new NodeId("al-1.Condition", 2),
|
||||||
|
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
|
||||||
|
};
|
||||||
|
|
||||||
|
var calls = new List<CallMethodRequest> { requestNoArgs };
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { null! };
|
||||||
|
var index = Index(("al-1.Condition", "al-1"));
|
||||||
|
|
||||||
|
// Should not throw — comment defaults to null.
|
||||||
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||||
|
MakeIdentity("alice"), calls, results, errors, engine, index);
|
||||||
|
|
||||||
|
ServiceResult.IsBad(errors[0]).ShouldBeFalse("Acknowledge without comment succeeds");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Acknowledge_marks_slot_result_as_Good_and_error_as_Good()
|
||||||
|
{
|
||||||
|
using var engine = BuildActiveEngine("al-1");
|
||||||
|
|
||||||
|
var calls = new List<CallMethodRequest> { AcknowledgeRequest("al-1.Condition") };
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { null! };
|
||||||
|
var index = Index(("al-1.Condition", "al-1"));
|
||||||
|
|
||||||
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||||
|
MakeIdentity("alice"), calls, results, errors, engine, index);
|
||||||
|
|
||||||
|
results[0].StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
|
||||||
|
errors[0].ShouldBe(ServiceResult.Good);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Confirm -----------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Confirm_on_alarm_with_unconfirmed_state_advances_state()
|
||||||
|
{
|
||||||
|
// Build an alarm pre-seeded as Inactive + Acknowledged + Unconfirmed so
|
||||||
|
// ApplyConfirm has a valid transition to execute.
|
||||||
|
var store = new InMemoryAlarmStateStore();
|
||||||
|
var upstream = new CachedTagUpstreamSource();
|
||||||
|
var logger = new LoggerConfiguration().CreateLogger();
|
||||||
|
var factory = new ScriptLoggerFactory(logger);
|
||||||
|
var engine = new ScriptedAlarmEngine(upstream, store, factory, logger);
|
||||||
|
|
||||||
|
var seedState = CoreAlarmConditionState.Fresh("confirm-alarm", DateTime.UtcNow) with
|
||||||
|
{
|
||||||
|
Active = AlarmActiveState.Inactive,
|
||||||
|
Acked = AlarmAckedState.Acknowledged,
|
||||||
|
Confirmed = AlarmConfirmedState.Unconfirmed,
|
||||||
|
};
|
||||||
|
store.SaveAsync(seedState, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
var defs = new List<ScriptedAlarmDefinition>
|
||||||
|
{
|
||||||
|
new("confirm-alarm", "/eq", "confirm-alarm", AlarmKind.LimitAlarm,
|
||||||
|
AlarmSeverity.Low, "msg", "return false;"),
|
||||||
|
};
|
||||||
|
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
engine.GetState("confirm-alarm")!.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
|
||||||
|
|
||||||
|
var calls = new List<CallMethodRequest>
|
||||||
|
{
|
||||||
|
ConfirmRequest("confirm-alarm.Condition", "all clear"),
|
||||||
|
};
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { null! };
|
||||||
|
var index = Index(("confirm-alarm.Condition", "confirm-alarm"));
|
||||||
|
|
||||||
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||||
|
MakeIdentity("ops-user"), calls, results, errors, engine, index);
|
||||||
|
|
||||||
|
ServiceResult.IsBad(errors[0]).ShouldBeFalse("Confirm succeeded");
|
||||||
|
engine.GetState("confirm-alarm")!.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
|
||||||
|
engine.GetState("confirm-alarm")!.LastConfirmUser.ShouldBe("ops-user");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Mixed batches -----------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Mixed_batch_handles_each_slot_independently()
|
||||||
|
{
|
||||||
|
using var engine = BuildActiveEngine("al-1");
|
||||||
|
|
||||||
|
var calls = new List<CallMethodRequest>
|
||||||
|
{
|
||||||
|
AcknowledgeRequest("al-1.Condition"), // scripted alarm → handled
|
||||||
|
GenericRequest("some-driver-method"), // non-alarm → pass through
|
||||||
|
AcknowledgeRequest("unknown-alarm.Condition"), // not in index → pass through
|
||||||
|
};
|
||||||
|
var results = Enumerable.Range(0, 3).Select(_ => new CallMethodResult()).ToList();
|
||||||
|
var errors = new List<ServiceResult> { null!, null!, null! };
|
||||||
|
var index = Index(("al-1.Condition", "al-1")); // only one entry
|
||||||
|
|
||||||
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||||
|
MakeIdentity("alice"), calls, results, errors, engine, index);
|
||||||
|
|
||||||
|
// Slot 0: Acknowledge on known scripted alarm → handled with Good result.
|
||||||
|
ServiceResult.IsBad(errors[0]).ShouldBeFalse("scripted alarm Acknowledge handled");
|
||||||
|
engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||||
|
|
||||||
|
// Slot 1: Generic method → left null for base.Call.
|
||||||
|
errors[1].ShouldBeNull("generic method left for base.Call");
|
||||||
|
|
||||||
|
// Slot 2: Unknown alarm id → left null for base.Call.
|
||||||
|
errors[2].ShouldBeNull("unknown condition id left for base.Call");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_alarm_id_in_engine_returns_BadInvalidArgument()
|
||||||
|
{
|
||||||
|
using var engine = BuildEngine("al-1");
|
||||||
|
|
||||||
|
// The index says al-999 maps to "al-999-engine" but the engine has no such alarm.
|
||||||
|
var calls = new List<CallMethodRequest>
|
||||||
|
{
|
||||||
|
AcknowledgeRequest("al-999.Condition"),
|
||||||
|
};
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { null! };
|
||||||
|
// Put a deliberately wrong alarmId in the index (engine will throw ArgumentException).
|
||||||
|
var index = Index(("al-999.Condition", "al-999-not-in-engine"));
|
||||||
|
|
||||||
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||||
|
MakeIdentity("alice"), calls, results, errors, engine, index);
|
||||||
|
|
||||||
|
ServiceResult.IsBad(errors[0]).ShouldBeTrue("unknown alarm in engine → error result");
|
||||||
|
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Phase7ComposedSources helpers -------------------------------------
|
||||||
|
|
||||||
|
private static Script ScriptRow(string id, string source) => new()
|
||||||
|
{
|
||||||
|
ScriptRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static VirtualTag VtRow(string id, string scriptId) => new()
|
||||||
|
{
|
||||||
|
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
VirtualTagId = id, EquipmentId = "eq-1", Name = id,
|
||||||
|
DataType = "Float32", ScriptId = scriptId,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ScriptedAlarm AlarmRow(string id, string scriptId) => new()
|
||||||
|
{
|
||||||
|
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
ScriptedAlarmId = id, EquipmentId = "eq-1", Name = id,
|
||||||
|
AlarmType = "LimitAlarm", Severity = 500,
|
||||||
|
MessageTemplate = "x", PredicateScriptId = scriptId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Fake user identity ------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple <see cref="UserIdentity"/> with a display name for unit testing.
|
||||||
|
/// Uses the <c>UserIdentity(username, password)</c> constructor so the base-class
|
||||||
|
/// <see cref="UserIdentity.DisplayName"/> property returns the supplied name when
|
||||||
|
/// accessed through the <see cref="IUserIdentity"/> interface.
|
||||||
|
/// The real production identity is <c>OtOpcUaServer.RoleBasedIdentity</c> which
|
||||||
|
/// populates DisplayName from the LDAP authentication result.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class NamedUserIdentity(string displayName) : UserIdentity(displayName, "") { }
|
||||||
|
}
|
||||||
@@ -207,9 +207,31 @@ public sealed class ThreeUserInteropMatrixTests
|
|||||||
// pins the resolution explicitly in strict mode.
|
// pins the resolution explicitly in strict mode.
|
||||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893.");
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893.");
|
||||||
|
|
||||||
var auth = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken);
|
// Under parallel full-solution test load, GLAuth on localhost can be slow to
|
||||||
|
// respond; use a generous per-call timeout independent of xUnit's test runner
|
||||||
|
// deadline so we don't race against the runner's own CancellationToken, and
|
||||||
|
// retry once on timeout to absorb transient latency spikes.
|
||||||
|
const int LdapTimeoutSeconds = 15;
|
||||||
|
UserAuthResult? auth = null;
|
||||||
|
for (var attempt = 0; attempt < 2; attempt++)
|
||||||
|
{
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
cts.CancelAfter(TimeSpan.FromSeconds(LdapTimeoutSeconds));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
auth = await NewAuthenticator().AuthenticateAsync("admin", "admin123", cts.Token);
|
||||||
|
break; // success — no retry needed
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!TestContext.Current.CancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (attempt == 1) throw; // second attempt also timed out — let it fail
|
||||||
|
// First attempt timed out under load; retry once with a fresh token.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
auth.Success.ShouldBeTrue();
|
auth.ShouldNotBeNull();
|
||||||
|
auth!.Success.ShouldBeTrue();
|
||||||
auth.Groups.ShouldContain("ReadOnly");
|
auth.Groups.ShouldContain("ReadOnly");
|
||||||
auth.Groups.ShouldContain("WriteOperate");
|
auth.Groups.ShouldContain("WriteOperate");
|
||||||
auth.Groups.ShouldContain("WriteTune");
|
auth.Groups.ShouldContain("WriteTune");
|
||||||
|
|||||||
Reference in New Issue
Block a user