feat(adminui): F15.3 closes F15 — live alerts/script-log, CSV import, Monaco editor
Some checks failed
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been cancelled
v2-ci / build (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been cancelled
v2-ci / integration (push) Has been cancelled

Final F15 batch wires up the SignalR-backed live pages, ports the bulk
equipment importer, and progressively enhances the Script source editor
with Monaco.

Message contracts:
- Commons.Messages.Alerts.AlarmTransitionEvent — fires on every alarm
  state transition; published on the `alerts` DPS topic by future
  ScriptedAlarmActor (F9) emits.
- Commons.Messages.Logging.ScriptLogEntry — one log line emitted by a
  hosted script; published on the `script-logs` DPS topic by future
  VirtualTagActor (F8) + ScriptedAlarmActor (F9) emits.
  (Folder named "Logging" to dodge .gitignore's "logs/" rule.)

SignalR plumbing:
- AlertHub gains MethodName + bridge actor (AlertSignalRBridge)
- ScriptLogHub introduced; ScriptLogSignalRBridge follows the same
  DPS-subscribe → IHubContext fan-out pattern as FleetStatusSignalRBridge
- WithOtOpcUaSignalRBridges now spawns all three bridges
- MapOtOpcUaHubs maps /hubs/script-log alongside the existing hubs

Pages:
- /alerts                      live alarm tail, 200-row capacity
- /script-log                  live script-log tail with level + script
                               filter, 500-row capacity
- /clusters/{id}/equipment/import — CSV bulk Equipment add with preview
                                    (Name/MachineCode/UnsLineId/Driver +
                                    optional ZTag/SAPID/Manufacturer/Model;
                                    skips rows whose MachineCode already
                                    exists in the fleet)
- ScriptEdit progressively enhanced with Monaco editor via JSInterop —
  the textarea remains Blazor's source of truth and Monaco syncs into it
  on every keystroke so @bind keeps working; falls back gracefully if
  the CDN is unreachable.

MainLayout nav gains a "Live" section (Deployments, Alerts, Alarms
historian) and a "Scripts" link under Scripting. ClusterEquipment
surfaces the new Import CSV button.

Tally: F15 ships ~42 razor pages + 3 SignalR hubs + 3 bridge actors.
Microsoft.AspNetCore.SignalR.Client added (was already in central PM).

All 104 v2 tests remain green.
This commit is contained in:
Joseph Doherty
2026-05-26 08:39:17 -04:00
parent e248e037e7
commit 59858129cb
15 changed files with 764 additions and 12 deletions

View File

@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
/// <summary>
/// Live alarm transition published on the cluster <c>alerts</c> DistributedPubSub topic.
/// Emitted by ScriptedAlarmActor (and future native-alarm bridges) when an alarm condition
/// transitions; consumed by <c>AlertSignalRBridge</c> for browser fan-out and by historian
/// adapters for durable storage.
/// </summary>
/// <param name="AlarmId">Stable condition identity (matches <c>ScriptedAlarm.ScriptedAlarmId</c> for scripted alarms).</param>
/// <param name="EquipmentPath">UNS path of the Equipment node the alarm hangs under. Doubles as the SourceNode.</param>
/// <param name="AlarmName">Operator-visible alarm name.</param>
/// <param name="TransitionKind">Activated / Cleared / Acknowledged / Confirmed / Shelved / Unshelved / Disabled / Enabled / CommentAdded.</param>
/// <param name="Severity">11000 numeric severity (OPC UA convention).</param>
/// <param name="Message">Fully-rendered message text — template tokens already resolved.</param>
/// <param name="User">Operator who triggered the transition. "system" for engine-driven events.</param>
/// <param name="TimestampUtc">When the transition occurred.</param>
public sealed record AlarmTransitionEvent(
string AlarmId,
string EquipmentPath,
string AlarmName,
string TransitionKind,
int Severity,
string Message,
string User,
DateTime TimestampUtc);

View File

@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
/// <summary>
/// One line of script log output published on the cluster <c>script-logs</c> DPS topic.
/// Emitted by VirtualTagActor + ScriptedAlarmActor when their hosted scripts call into
/// the runtime's logging facade; consumed by <c>ScriptLogSignalRBridge</c> for live
/// browser tail-style viewing.
/// </summary>
/// <param name="ScriptId">The Script row this entry came from (matches <c>Script.ScriptId</c>).</param>
/// <param name="Level">"Trace" / "Debug" / "Information" / "Warning" / "Error" / "Critical" — Serilog levels.</param>
/// <param name="Message">Operator-facing log message; template tokens already resolved.</param>
/// <param name="TimestampUtc">When the script emitted the entry.</param>
/// <param name="VirtualTagId">VirtualTag context, if logged from a virtual tag evaluation. Null otherwise.</param>
/// <param name="AlarmId">ScriptedAlarm context, if logged from an alarm predicate. Null otherwise.</param>
/// <param name="EquipmentId">Equipment scope, if the script ran in a per-equipment context. Null for fleet-wide scripts.</param>
public sealed record ScriptLogEntry(
string ScriptId,
string Level,
string Message,
DateTime TimestampUtc,
string? VirtualTagId,
string? AlarmId,
string? EquipmentId);

View File

@@ -46,8 +46,14 @@
<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="/scripts" Match="NavLinkMatch.Prefix">Scripts</NavLink>
<NavLink class="rail-link" href="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
<div class="rail-eyebrow">Live</div>
<NavLink class="rail-link" href="/deployments" Match="NavLinkMatch.Prefix">Deployments</NavLink>
<NavLink class="rail-link" href="/alerts" Match="NavLinkMatch.Prefix">Alerts</NavLink>
<NavLink class="rail-link" href="/alarms-historian" Match="NavLinkMatch.Prefix">Alarms historian</NavLink>
<div class="rail-foot">
<AuthorizeView>
<Authorized>

View File

@@ -0,0 +1,126 @@
@page "/alerts"
@* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent
AlarmTransitionEvent entries. Engine wiring (ScriptedAlarmActor publish on the `alerts`
topic) lands with F9; until then the connection stays open and the table is empty. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts
@inject NavigationManager Nav
@implements IAsyncDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Alerts</h4>
<div class="d-flex align-items-center gap-2">
<span class="conn-pill" data-state="@(_connected ? "connected" : "disconnected")">
<span class="dot"></span><span>@(_connected ? "live" : "disconnected")</span>
</span>
<button class="btn btn-sm btn-outline-secondary" @onclick="ClearAsync">Clear</button>
</div>
</div>
<section class="panel notice rise" style="animation-delay:.02s">
Live alarm transitions from the cluster's <span class="mono">alerts</span> DPS topic. Shows
the most-recent @Capacity entries since the page opened; reload for a fresh window. Sources:
ScriptedAlarmActor, native AB CIP ALMD bridge (F9), Galaxy alarm bridge (future).
</section>
@if (_rows.Count == 0)
{
<section class="panel notice rise mt-3" style="animation-delay:.08s">
No alarms yet. Engine wiring (F9 ScriptedAlarmActor) is pending; once it ships the table
below will start populating in real time.
</section>
}
else
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Recent transitions (@_rows.Count)</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Time</th>
<th>Alarm</th>
<th>Equipment</th>
<th>Kind</th>
<th class="num">Severity</th>
<th>User</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@foreach (var e in _rows)
{
<tr>
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
<td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
<td><span class="mono small">@e.EquipmentPath</span></td>
<td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
<td class="num">@e.Severity</td>
<td>@e.User</td>
<td>@e.Message</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
private const int Capacity = 200;
private readonly List<AlarmTransitionEvent> _rows = new();
private HubConnection? _hub;
private bool _connected;
protected override async Task OnInitializedAsync()
{
_hub = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri(AlertHub.Endpoint))
.WithAutomaticReconnect()
.Build();
_hub.On<AlarmTransitionEvent>(AlertHub.MethodName, evt =>
{
_rows.Insert(0, evt);
if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1);
InvokeAsync(StateHasChanged);
});
_hub.Closed += _ => { _connected = false; return InvokeAsync(StateHasChanged); };
_hub.Reconnected += _ => { _connected = true; return InvokeAsync(StateHasChanged); };
try
{
await _hub.StartAsync();
_connected = true;
}
catch
{
// Connection failures (admin-only deployment, hub not mapped, etc.) leave the page
// showing "disconnected" — operator action: reload or talk to the host operator.
}
}
private async Task ClearAsync()
{
_rows.Clear();
await InvokeAsync(StateHasChanged);
}
private static string KindChipClass(string kind) => kind switch
{
"Activated" => "chip-alert",
"Cleared" => "chip-ok",
"Acknowledged" or "Confirmed" => "chip-caution",
"Shelved" or "Disabled" => "chip-idle",
_ => "chip-idle",
};
public async ValueTask DisposeAsync()
{
if (_hub is not null) await _hub.DisposeAsync();
}
}

View File

@@ -8,7 +8,10 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Equipment &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/equipment/new" class="btn btn-primary btn-sm">New equipment</a>
<div class="d-flex gap-2">
<a href="/clusters/@ClusterId/equipment/import" class="btn btn-outline-primary btn-sm">Import CSV…</a>
<a href="/clusters/@ClusterId/equipment/new" class="btn btn-primary btn-sm">New equipment</a>
</div>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="equipment" />

View File

@@ -0,0 +1,256 @@
@page "/clusters/{ClusterId}/equipment/import"
@* Bulk equipment import via pasted CSV. Header row required; columns:
Name, MachineCode, UnsLineId, DriverInstanceId, ZTag, SAPID, Manufacturer, Model
Empty optional columns parsed as null. EquipmentId is system-generated per row
(matches single-add path in EquipmentEdit.razor). *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Import equipment &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="equipment" />
<section class="panel notice rise" style="animation-delay:.02s">
Paste CSV below. Required header columns (in order):
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>.
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
Each row inserts one Equipment with a freshly-generated EquipmentId. Existing rows are
detected by MachineCode and skipped (the importer is additive-only — no updates).
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">CSV</div>
<div style="padding:1rem">
<textarea class="form-control form-control-sm mono" rows="14"
@bind="_csv" @bind:event="oninput"
placeholder="Name,MachineCode,UnsLineId,DriverInstanceId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,line-3,drv-modbus-line3-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
}
@if (_preview is not null)
{
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Preview &middot; @_preview.Count row@(_preview.Count == 1 ? "" : "s") to import</div>
@if (_preview.Count > 0)
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>MachineCode</th>
<th>UNS line</th>
<th>Driver</th>
<th>ZTag</th>
<th>SAPID</th>
<th>Manufacturer</th>
<th>Model</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var p in _preview)
{
<tr>
<td>@p.Name</td>
<td><span class="mono">@p.MachineCode</span></td>
<td><span class="mono small">@p.UnsLineId</span></td>
<td><span class="mono small">@p.DriverInstanceId</span></td>
<td>@(p.ZTag ?? "")</td>
<td>@(p.SAPID ?? "")</td>
<td>@(p.Manufacturer ?? "")</td>
<td>@(p.Model ?? "")</td>
<td>
@if (p.IsSkipped) { <span class="chip chip-idle">skip — exists</span> }
else if (!string.IsNullOrEmpty(p.RowError)) { <span class="chip chip-alert">@p.RowError</span> }
else { <span class="chip chip-ok">ready</span> }
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-outline-primary" @onclick="PreviewAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Preview
</button>
<button class="btn btn-primary" @onclick="ImportAsync"
disabled="@(_busy || _preview is null || _preview.All(p => p.IsSkipped || !string.IsNullOrEmpty(p.RowError)))">
Import @(_preview?.Count(p => !p.IsSkipped && string.IsNullOrEmpty(p.RowError)) ?? 0) row(s)
</button>
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary">Cancel</a>
</div>
@code {
[Parameter] public string ClusterId { get; set; } = "";
private string _csv = "";
private List<PreviewRow>? _preview;
private bool _busy;
private string? _error;
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"];
private static readonly string[] OptionalColumns = ["ZTag", "SAPID", "Manufacturer", "Model"];
private async Task PreviewAsync()
{
_busy = true;
_error = null;
_preview = null;
try
{
var parsed = ParseCsv(_csv);
if (parsed is null) return;
await using var db = await DbFactory.CreateDbContextAsync();
var driversInCluster = await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId)
.Select(d => d.DriverInstanceId)
.ToListAsync();
var driverSet = driversInCluster.ToHashSet(StringComparer.Ordinal);
var areaIds = await db.UnsAreas.AsNoTracking()
.Where(a => a.ClusterId == ClusterId)
.Select(a => a.UnsAreaId).ToListAsync();
var validLines = await db.UnsLines.AsNoTracking()
.Where(l => areaIds.Contains(l.UnsAreaId))
.Select(l => l.UnsLineId).ToListAsync();
var lineSet = validLines.ToHashSet(StringComparer.Ordinal);
var existingMachineCodes = await db.Equipment.AsNoTracking()
.Select(e => e.MachineCode).ToListAsync();
var existingSet = existingMachineCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var row in parsed)
{
if (existingSet.Contains(row.MachineCode))
{
row.IsSkipped = true;
continue;
}
if (!driverSet.Contains(row.DriverInstanceId))
{
row.RowError = $"driver '{row.DriverInstanceId}' not in this cluster";
continue;
}
if (!lineSet.Contains(row.UnsLineId))
{
row.RowError = $"UNS line '{row.UnsLineId}' not in this cluster";
}
}
_preview = parsed;
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task ImportAsync()
{
if (_preview is null) return;
_busy = true;
_error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var added = 0;
foreach (var row in _preview.Where(p => !p.IsSkipped && string.IsNullOrEmpty(p.RowError)))
{
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
db.Equipment.Add(new Equipment
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
DriverInstanceId = row.DriverInstanceId,
UnsLineId = row.UnsLineId,
Name = row.Name,
MachineCode = row.MachineCode,
ZTag = row.ZTag,
SAPID = row.SAPID,
Manufacturer = row.Manufacturer,
Model = row.Model,
Enabled = true,
});
added++;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/equipment");
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private List<PreviewRow>? ParseCsv(string csv)
{
if (string.IsNullOrWhiteSpace(csv)) { _error = "CSV is empty."; return null; }
var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 2) { _error = "Need a header row and at least one data row."; return null; }
var header = lines[0].Split(',').Select(c => c.Trim()).ToArray();
for (var i = 0; i < RequiredColumns.Length; i++)
{
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
{
_error = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').";
return null;
}
}
var rows = new List<PreviewRow>();
for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++)
{
var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray();
if (parts.Length < RequiredColumns.Length)
{
rows.Add(new PreviewRow { RowError = $"too few columns (got {parts.Length}, need {RequiredColumns.Length})" });
continue;
}
rows.Add(new PreviewRow
{
Name = parts[0],
MachineCode = parts[1],
UnsLineId = parts[2],
DriverInstanceId = parts[3],
ZTag = NullIfEmpty(parts, 4),
SAPID = NullIfEmpty(parts, 5),
Manufacturer = NullIfEmpty(parts, 6),
Model = NullIfEmpty(parts, 7),
});
}
return rows;
}
private static string? NullIfEmpty(string[] parts, int idx) =>
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
private sealed class PreviewRow
{
public string Name { get; set; } = "";
public string MachineCode { get; set; } = "";
public string UnsLineId { get; set; } = "";
public string DriverInstanceId { get; set; } = "";
public string? ZTag { get; set; }
public string? SAPID { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public bool IsSkipped { get; set; }
public string? RowError { get; set; }
}
}

View File

@@ -13,6 +13,7 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@inject IJSRuntime JS
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New script" : "Edit script")</h4>
@@ -56,10 +57,15 @@ else
<section class="panel rise mt-3">
<div class="panel-head">Source</div>
<div style="padding:1rem">
<InputTextArea @bind-Value="_form.SourceCode" class="form-control form-control-sm mono"
rows="20"
placeholder="// C# expression body — Monaco editor lands in a follow-up." />
<div class="form-text">SHA-256 hash is computed automatically on save.</div>
@* The textarea stays in the DOM and remains Blazor's source of truth. Monaco
mounts a <div> beside it (textarea hides), and the loader's onDidChangeModelContent
handler mirrors edits back into the textarea + fires the input event so @bind
picks them up. Falls back to the textarea gracefully if Monaco's CDN is
unreachable (air-gapped deployments — see monaco-loader.js). *@
<InputTextArea id="script-source" @bind-Value="_form.SourceCode"
class="form-control form-control-sm mono" rows="20"
placeholder="// C# expression body" />
<div class="form-text">SHA-256 hash is computed automatically on save. Monaco editor attaches over the textarea on render.</div>
</div>
</section>
@@ -104,6 +110,24 @@ else
_loaded = true;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || !_loaded) return;
// Inject loader once, then attach over the textarea. Failures are silent — the page
// is fully usable via the underlying textarea if Monaco's CDN is unreachable.
try
{
await JS.InvokeVoidAsync("eval", "if (!document.querySelector('script[data-otopcua=monaco-loader]')) { var s=document.createElement('script'); s.src='/_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-loader.js'; s.dataset.otopcua='monaco-loader'; document.head.appendChild(s); }");
// Wait a tick for the loader IIFE to register window.otOpcUaScriptEditor, then attach.
await Task.Delay(50);
await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", "script-source");
}
catch
{
// Textarea remains the editor — no-op.
}
}
private async Task SubmitAsync()
{
_busy = true; _error = null;

View File

@@ -0,0 +1,163 @@
@page "/script-log"
@* Live script-log tail via SignalR. Subscribes to /hubs/script-log and shows entries from
VirtualTagActor / ScriptedAlarmActor script execution. Engine emit lands with F8 + F9. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging
@inject NavigationManager Nav
@implements IAsyncDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Script log</h4>
<div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" style="width:auto" @bind="_levelFilter">
<option value="">All levels</option>
<option value="Trace">Trace+</option>
<option value="Debug">Debug+</option>
<option value="Information">Information+</option>
<option value="Warning">Warning+</option>
<option value="Error">Error+</option>
</select>
<input type="text" class="form-control form-control-sm" style="width:200px"
placeholder="Filter script ID…" @bind="_scriptFilter" @bind:event="oninput" />
<span class="conn-pill" data-state="@(_connected ? "connected" : "disconnected")">
<span class="dot"></span><span>@(_connected ? "live" : "disconnected")</span>
</span>
<button class="btn btn-sm btn-outline-secondary" @onclick="ClearAsync">Clear</button>
</div>
</div>
<section class="panel notice rise" style="animation-delay:.02s">
Live tail of <span class="mono">script-logs</span> DPS topic, capped at @Capacity entries.
Filter by minimum level + script ID. Sources: VirtualTagActor (F8), ScriptedAlarmActor (F9).
</section>
@if (VisibleRows.Count == 0)
{
<section class="panel notice rise mt-3" style="animation-delay:.08s">
@if (_rows.Count == 0)
{
<span>No script-log entries yet. Engine emit (F8/F9) is pending.</span>
}
else
{
<span>No entries match the current filter (@_rows.Count entries available).</span>
}
</section>
}
else
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Showing @VisibleRows.Count of @_rows.Count</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Time</th>
<th>Level</th>
<th>Script</th>
<th>Context</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@foreach (var e in VisibleRows)
{
<tr>
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
<td><span class="chip @LevelChipClass(e.Level)">@e.Level</span></td>
<td><span class="mono small">@e.ScriptId</span></td>
<td class="text-muted small">
@if (!string.IsNullOrEmpty(e.VirtualTagId)) { <span>vtag=@e.VirtualTagId</span> }
@if (!string.IsNullOrEmpty(e.AlarmId)) { <span class="ms-1">alarm=@e.AlarmId</span> }
@if (!string.IsNullOrEmpty(e.EquipmentId)) { <span class="ms-1">eq=@e.EquipmentId</span> }
</td>
<td><span class="mono small">@e.Message</span></td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
private const int Capacity = 500;
private readonly List<ScriptLogEntry> _rows = new();
private HubConnection? _hub;
private bool _connected;
private string _levelFilter = "";
private string _scriptFilter = "";
private static readonly Dictionary<string, int> LevelRank = new(StringComparer.OrdinalIgnoreCase)
{
["Trace"] = 0, ["Debug"] = 1, ["Information"] = 2, ["Warning"] = 3, ["Error"] = 4, ["Critical"] = 5,
};
private List<ScriptLogEntry> VisibleRows
{
get
{
IEnumerable<ScriptLogEntry> q = _rows;
if (!string.IsNullOrWhiteSpace(_levelFilter)
&& LevelRank.TryGetValue(_levelFilter, out var minRank))
{
q = q.Where(e => LevelRank.TryGetValue(e.Level, out var r) && r >= minRank);
}
if (!string.IsNullOrWhiteSpace(_scriptFilter))
{
q = q.Where(e => e.ScriptId.Contains(_scriptFilter, StringComparison.OrdinalIgnoreCase));
}
return q.ToList();
}
}
protected override async Task OnInitializedAsync()
{
_hub = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri(ScriptLogHub.Endpoint))
.WithAutomaticReconnect()
.Build();
_hub.On<ScriptLogEntry>(ScriptLogHub.MethodName, entry =>
{
_rows.Insert(0, entry);
if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1);
InvokeAsync(StateHasChanged);
});
_hub.Closed += _ => { _connected = false; return InvokeAsync(StateHasChanged); };
_hub.Reconnected += _ => { _connected = true; return InvokeAsync(StateHasChanged); };
try
{
await _hub.StartAsync();
_connected = true;
}
catch
{
// Connection error — page shows "disconnected".
}
}
private async Task ClearAsync()
{
_rows.Clear();
await InvokeAsync(StateHasChanged);
}
private static string LevelChipClass(string level) => level switch
{
"Critical" or "Error" => "chip-alert",
"Warning" => "chip-caution",
"Information" => "chip-idle",
_ => "chip-idle",
};
public async ValueTask DisposeAsync()
{
if (_hub is not null) await _hub.DisposeAsync();
}
}

View File

@@ -2,8 +2,14 @@ using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// <summary>Browser-facing alert / toast push channel. Bridge wiring staged for F16.</summary>
/// <summary>
/// Browser-facing alert push channel. Subscribers receive
/// <see cref="Commons.Messages.Alerts.AlarmTransitionEvent"/> snapshots whenever an alarm fires,
/// clears, or is acknowledged on any cluster node. Bridge: <c>AlertSignalRBridge</c> subscribes
/// to the <c>alerts</c> DPS topic and forwards to every connected SignalR client.
/// </summary>
public sealed class AlertHub : Hub
{
public const string Endpoint = "/hubs/alerts";
public const string MethodName = "alarmTransition";
}

View File

@@ -0,0 +1,46 @@
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.AspNetCore.SignalR;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// <summary>
/// Akka actor that subscribes to the <c>alerts</c> DistributedPubSub topic and forwards each
/// <see cref="AlarmTransitionEvent"/> to every SignalR client connected to <see cref="AlertHub"/>.
/// Mirrors <c>FleetStatusSignalRBridge</c>'s design — one bridge per admin node, hub fan-out is
/// per-node, no cluster-singleton needed.
/// </summary>
public sealed class AlertSignalRBridge : ReceiveActor
{
public const string TopicName = "alerts";
private readonly IHubContext<AlertHub> _hub;
private readonly ILoggingAdapter _log = Context.GetLogger();
public static Props Props(IHubContext<AlertHub> hub) =>
Akka.Actor.Props.Create(() => new AlertSignalRBridge(hub));
public AlertSignalRBridge(IHubContext<AlertHub> hub)
{
_hub = hub;
ReceiveAsync<AlarmTransitionEvent>(ForwardAsync);
Receive<SubscribeAck>(_ => { /* DPS confirmation */ });
}
protected override void PreStart() =>
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(TopicName, Self));
private async Task ForwardAsync(AlarmTransitionEvent msg)
{
try
{
await _hub.Clients.All.SendAsync(AlertHub.MethodName, msg);
}
catch (Exception ex)
{
_log.Warning(ex, "AlertSignalRBridge: SignalR push failed for {AlarmId}", msg.AlarmId);
}
}
}

View File

@@ -9,6 +9,7 @@ public static class HubRouteBuilderExtensions
{
app.MapHub<FleetStatusHub>(FleetStatusHub.Endpoint);
app.MapHub<AlertHub>(AlertHub.Endpoint);
app.MapHub<ScriptLogHub>(ScriptLogHub.Endpoint);
return app;
}
}

View File

@@ -8,11 +8,13 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
public static class HubServiceCollectionExtensions
{
public const string FleetStatusSignalRBridgeName = "fleet-status-signalr-bridge";
public const string AlertSignalRBridgeName = "alert-signalr-bridge";
public const string ScriptLogSignalRBridgeName = "script-log-signalr-bridge";
/// <summary>
/// Spawns the SignalR bridge actors that forward DPS messages to browser-facing SignalR
/// hubs. Currently: <see cref="FleetStatusSignalRBridge"/> (DPS <c>fleet-status</c> topic
/// <see cref="FleetStatusHub"/> clients).
/// hubs: <c>fleet-status</c> → <see cref="FleetStatusHub"/>, <c>alerts</c>
/// <see cref="AlertHub"/>, <c>script-logs</c> → <see cref="ScriptLogHub"/>.
///
/// Call inside the admin-role configurator on the shared <see cref="AkkaConfigurationBuilder"/>:
/// <code>
@@ -27,13 +29,23 @@ public static class HubServiceCollectionExtensions
{
builder.WithActors((system, registry, resolver) =>
{
var hub = resolver.GetService<IHubContext<FleetStatusHub>>();
var actor = system.ActorOf(FleetStatusSignalRBridge.Props(hub), FleetStatusSignalRBridgeName);
registry.Register<FleetStatusSignalRBridgeKey>(actor);
var fleetHub = resolver.GetService<IHubContext<FleetStatusHub>>();
var fleetBridge = system.ActorOf(FleetStatusSignalRBridge.Props(fleetHub), FleetStatusSignalRBridgeName);
registry.Register<FleetStatusSignalRBridgeKey>(fleetBridge);
var alertHub = resolver.GetService<IHubContext<AlertHub>>();
var alertBridge = system.ActorOf(AlertSignalRBridge.Props(alertHub), AlertSignalRBridgeName);
registry.Register<AlertSignalRBridgeKey>(alertBridge);
var scriptLogHub = resolver.GetService<IHubContext<ScriptLogHub>>();
var scriptLogBridge = system.ActorOf(ScriptLogSignalRBridge.Props(scriptLogHub), ScriptLogSignalRBridgeName);
registry.Register<ScriptLogSignalRBridgeKey>(scriptLogBridge);
});
return builder;
}
}
/// <summary>Marker key for <see cref="ActorRegistry"/> lookup of the SignalR bridge actor.</summary>
/// <summary>Marker keys for <see cref="ActorRegistry"/> lookup of the SignalR bridge actors.</summary>
public sealed class FleetStatusSignalRBridgeKey { }
public sealed class AlertSignalRBridgeKey { }
public sealed class ScriptLogSignalRBridgeKey { }

View File

@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// <summary>
/// Browser-facing script-log push channel. Subscribers receive
/// <see cref="Commons.Messages.Logging.ScriptLogEntry"/> lines emitted by VirtualTagActor +
/// ScriptedAlarmActor as their hosted scripts log diagnostic output. Bridge:
/// <c>ScriptLogSignalRBridge</c> subscribes to the <c>script-logs</c> DPS topic and forwards
/// to every connected SignalR client.
/// </summary>
public sealed class ScriptLogHub : Hub
{
public const string Endpoint = "/hubs/script-log";
public const string MethodName = "scriptLogEntry";
}

View File

@@ -0,0 +1,44 @@
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.AspNetCore.SignalR;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// <summary>
/// Akka actor that subscribes to the <c>script-logs</c> DistributedPubSub topic and forwards each
/// <see cref="ScriptLogEntry"/> to every SignalR client connected to <see cref="ScriptLogHub"/>.
/// </summary>
public sealed class ScriptLogSignalRBridge : ReceiveActor
{
public const string TopicName = "script-logs";
private readonly IHubContext<ScriptLogHub> _hub;
private readonly ILoggingAdapter _log = Context.GetLogger();
public static Props Props(IHubContext<ScriptLogHub> hub) =>
Akka.Actor.Props.Create(() => new ScriptLogSignalRBridge(hub));
public ScriptLogSignalRBridge(IHubContext<ScriptLogHub> hub)
{
_hub = hub;
ReceiveAsync<ScriptLogEntry>(ForwardAsync);
Receive<SubscribeAck>(_ => { /* DPS confirmation */ });
}
protected override void PreStart() =>
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(TopicName, Self));
private async Task ForwardAsync(ScriptLogEntry msg)
{
try
{
await _hub.Clients.All.SendAsync(ScriptLogHub.MethodName, msg);
}
catch (Exception ex)
{
_log.Warning(ex, "ScriptLogSignalRBridge: SignalR push failed for {ScriptId}", msg.ScriptId);
}
}
}

View File

@@ -8,6 +8,7 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
</ItemGroup>
<ItemGroup>