From 59858129cb2f87d9b381fb361829a33fc1cdfbc4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 08:39:17 -0400 Subject: [PATCH] =?UTF-8?q?feat(adminui):=20F15.3=20closes=20F15=20?= =?UTF-8?q?=E2=80=94=20live=20alerts/script-log,=20CSV=20import,=20Monaco?= =?UTF-8?q?=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Messages/Alerts/AlarmTransitionEvent.cs | 25 ++ .../Messages/Logging/ScriptLogEntry.cs | 23 ++ .../Components/Layout/MainLayout.razor | 6 + .../Components/Pages/Alerts.razor | 126 +++++++++ .../Pages/Clusters/ClusterEquipment.razor | 5 +- .../Pages/Clusters/ImportEquipment.razor | 256 ++++++++++++++++++ .../Components/Pages/ScriptEdit.razor | 32 ++- .../Components/Pages/ScriptLog.razor | 163 +++++++++++ .../Hubs/AlertHub.cs | 8 +- .../Hubs/AlertSignalRBridge.cs | 46 ++++ .../Hubs/HubRouteBuilderExtensions.cs | 1 + .../Hubs/HubServiceCollectionExtensions.cs | 24 +- .../Hubs/ScriptLogHub.cs | 16 ++ .../Hubs/ScriptLogSignalRBridge.cs | 44 +++ .../ZB.MOM.WW.OtOpcUa.AdminUI.csproj | 1 + 15 files changed, 764 insertions(+), 12 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Logging/ScriptLogEntry.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ImportEquipment.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogHub.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs new file mode 100644 index 0000000..53bb6ed --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs @@ -0,0 +1,25 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; + +/// +/// Live alarm transition published on the cluster alerts DistributedPubSub topic. +/// Emitted by ScriptedAlarmActor (and future native-alarm bridges) when an alarm condition +/// transitions; consumed by AlertSignalRBridge for browser fan-out and by historian +/// adapters for durable storage. +/// +/// Stable condition identity (matches ScriptedAlarm.ScriptedAlarmId for scripted alarms). +/// UNS path of the Equipment node the alarm hangs under. Doubles as the SourceNode. +/// Operator-visible alarm name. +/// Activated / Cleared / Acknowledged / Confirmed / Shelved / Unshelved / Disabled / Enabled / CommentAdded. +/// 1–1000 numeric severity (OPC UA convention). +/// Fully-rendered message text — template tokens already resolved. +/// Operator who triggered the transition. "system" for engine-driven events. +/// When the transition occurred. +public sealed record AlarmTransitionEvent( + string AlarmId, + string EquipmentPath, + string AlarmName, + string TransitionKind, + int Severity, + string Message, + string User, + DateTime TimestampUtc); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Logging/ScriptLogEntry.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Logging/ScriptLogEntry.cs new file mode 100644 index 0000000..fe43f37 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Logging/ScriptLogEntry.cs @@ -0,0 +1,23 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; + +/// +/// One line of script log output published on the cluster script-logs DPS topic. +/// Emitted by VirtualTagActor + ScriptedAlarmActor when their hosted scripts call into +/// the runtime's logging facade; consumed by ScriptLogSignalRBridge for live +/// browser tail-style viewing. +/// +/// The Script row this entry came from (matches Script.ScriptId). +/// "Trace" / "Debug" / "Information" / "Warning" / "Error" / "Critical" — Serilog levels. +/// Operator-facing log message; template tokens already resolved. +/// When the script emitted the entry. +/// VirtualTag context, if logged from a virtual tag evaluation. Null otherwise. +/// ScriptedAlarm context, if logged from an alarm predicate. Null otherwise. +/// Equipment scope, if the script ran in a per-equipment context. Null for fleet-wide scripts. +public sealed record ScriptLogEntry( + string ScriptId, + string Level, + string Message, + DateTime TimestampUtc, + string? VirtualTagId, + string? AlarmId, + string? EquipmentId); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor index 10f394d..e5a89e5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor @@ -46,8 +46,14 @@
Scripting
Virtual tags Scripted alarms + Scripts Script log +
Live
+ Deployments + Alerts + Alarms historian +
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor new file mode 100644 index 0000000..95964f9 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor @@ -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 + +
+

Alerts

+
+ + @(_connected ? "live" : "disconnected") + + +
+
+ +
+ Live alarm transitions from the cluster's alerts 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). +
+ +@if (_rows.Count == 0) +{ +
+ No alarms yet. Engine wiring (F9 ScriptedAlarmActor) is pending; once it ships the table + below will start populating in real time. +
+} +else +{ +
+
Recent transitions (@_rows.Count)
+
+ + + + + + + + + + + + + + @foreach (var e in _rows) + { + + + + + + + + + + } + +
TimeAlarmEquipmentKindSeverityUserMessage
@e.TimestampUtc.ToString("HH:mm:ss.fff")@e.AlarmId
@e.AlarmName
@e.EquipmentPath@e.TransitionKind@e.Severity@e.User@e.Message
+
+
+} + +@code { + private const int Capacity = 200; + + private readonly List _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(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(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEquipment.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEquipment.razor index 51ddd73..c1b18e0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEquipment.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEquipment.razor @@ -8,7 +8,10 @@

Equipment · @ClusterId

- New equipment +
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ImportEquipment.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ImportEquipment.razor new file mode 100644 index 0000000..e5dd0ee --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ImportEquipment.razor @@ -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 DbFactory +@inject NavigationManager Nav + +
+

Import equipment · @ClusterId

+ Cancel +
+ + + +
+ Paste CSV below. Required header columns (in order): + Name, MachineCode, UnsLineId, DriverInstanceId. + Optional: ZTag, SAPID, Manufacturer, Model. + 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). +
+ +
+
CSV
+
+ +
+
+ +@if (!string.IsNullOrWhiteSpace(_error)) +{ +
@_error
+} + +@if (_preview is not null) +{ +
+
Preview · @_preview.Count row@(_preview.Count == 1 ? "" : "s") to import
+ @if (_preview.Count > 0) + { +
+ + + + + + + + + + + + + + + + @foreach (var p in _preview) + { + + + + + + + + + + + + } + +
NameMachineCodeUNS lineDriverZTagSAPIDManufacturerModelStatus
@p.Name@p.MachineCode@p.UnsLineId@p.DriverInstanceId@(p.ZTag ?? "")@(p.SAPID ?? "")@(p.Manufacturer ?? "")@(p.Model ?? "") + @if (p.IsSkipped) { skip — exists } + else if (!string.IsNullOrEmpty(p.RowError)) { @p.RowError } + else { ready } +
+
+ } +
+} + +
+ + + Cancel +
+ +@code { + [Parameter] public string ClusterId { get; set; } = ""; + + private string _csv = ""; + private List? _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? 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(); + 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; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor index 2406c79..f7dd8ab 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor @@ -13,6 +13,7 @@ @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @inject IDbContextFactory DbFactory @inject NavigationManager Nav +@inject IJSRuntime JS

@(IsNew ? "New script" : "Edit script")

@@ -56,10 +57,15 @@ else
Source
- -
SHA-256 hash is computed automatically on save.
+ @* The textarea stays in the DOM and remains Blazor's source of truth. Monaco + mounts a
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). *@ + +
SHA-256 hash is computed automatically on save. Monaco editor attaches over the textarea on render.
@@ -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; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor new file mode 100644 index 0000000..fe28f7d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor @@ -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 + +
+

Script log

+
+ + + + @(_connected ? "live" : "disconnected") + + +
+
+ +
+ Live tail of script-logs DPS topic, capped at @Capacity entries. + Filter by minimum level + script ID. Sources: VirtualTagActor (F8), ScriptedAlarmActor (F9). +
+ +@if (VisibleRows.Count == 0) +{ +
+ @if (_rows.Count == 0) + { + No script-log entries yet. Engine emit (F8/F9) is pending. + } + else + { + No entries match the current filter (@_rows.Count entries available). + } +
+} +else +{ +
+
Showing @VisibleRows.Count of @_rows.Count
+
+ + + + + + + + + + + + @foreach (var e in VisibleRows) + { + + + + + + + + } + +
TimeLevelScriptContextMessage
@e.TimestampUtc.ToString("HH:mm:ss.fff")@e.Level@e.ScriptId + @if (!string.IsNullOrEmpty(e.VirtualTagId)) { vtag=@e.VirtualTagId } + @if (!string.IsNullOrEmpty(e.AlarmId)) { alarm=@e.AlarmId } + @if (!string.IsNullOrEmpty(e.EquipmentId)) { eq=@e.EquipmentId } + @e.Message
+
+
+} + +@code { + private const int Capacity = 500; + + private readonly List _rows = new(); + private HubConnection? _hub; + private bool _connected; + private string _levelFilter = ""; + private string _scriptFilter = ""; + + private static readonly Dictionary LevelRank = new(StringComparer.OrdinalIgnoreCase) + { + ["Trace"] = 0, ["Debug"] = 1, ["Information"] = 2, ["Warning"] = 3, ["Error"] = 4, ["Critical"] = 5, + }; + + private List VisibleRows + { + get + { + IEnumerable 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(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(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertHub.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertHub.cs index 541496a..50ed815 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertHub.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertHub.cs @@ -2,8 +2,14 @@ using Microsoft.AspNetCore.SignalR; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; -/// Browser-facing alert / toast push channel. Bridge wiring staged for F16. +/// +/// Browser-facing alert push channel. Subscribers receive +/// snapshots whenever an alarm fires, +/// clears, or is acknowledged on any cluster node. Bridge: AlertSignalRBridge subscribes +/// to the alerts DPS topic and forwards to every connected SignalR client. +/// public sealed class AlertHub : Hub { public const string Endpoint = "/hubs/alerts"; + public const string MethodName = "alarmTransition"; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs new file mode 100644 index 0000000..4d17a77 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs @@ -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; + +/// +/// Akka actor that subscribes to the alerts DistributedPubSub topic and forwards each +/// to every SignalR client connected to . +/// Mirrors FleetStatusSignalRBridge's design — one bridge per admin node, hub fan-out is +/// per-node, no cluster-singleton needed. +/// +public sealed class AlertSignalRBridge : ReceiveActor +{ + public const string TopicName = "alerts"; + + private readonly IHubContext _hub; + private readonly ILoggingAdapter _log = Context.GetLogger(); + + public static Props Props(IHubContext hub) => + Akka.Actor.Props.Create(() => new AlertSignalRBridge(hub)); + + public AlertSignalRBridge(IHubContext hub) + { + _hub = hub; + ReceiveAsync(ForwardAsync); + Receive(_ => { /* 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); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubRouteBuilderExtensions.cs index 95e1a5a..e93275b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubRouteBuilderExtensions.cs @@ -9,6 +9,7 @@ public static class HubRouteBuilderExtensions { app.MapHub(FleetStatusHub.Endpoint); app.MapHub(AlertHub.Endpoint); + app.MapHub(ScriptLogHub.Endpoint); return app; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs index 3b1e118..5efb4db 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs @@ -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"; /// /// Spawns the SignalR bridge actors that forward DPS messages to browser-facing SignalR - /// hubs. Currently: (DPS fleet-status topic → - /// clients). + /// hubs: fleet-status, alerts → + /// , script-logs. /// /// Call inside the admin-role configurator on the shared : /// @@ -27,13 +29,23 @@ public static class HubServiceCollectionExtensions { builder.WithActors((system, registry, resolver) => { - var hub = resolver.GetService>(); - var actor = system.ActorOf(FleetStatusSignalRBridge.Props(hub), FleetStatusSignalRBridgeName); - registry.Register(actor); + var fleetHub = resolver.GetService>(); + var fleetBridge = system.ActorOf(FleetStatusSignalRBridge.Props(fleetHub), FleetStatusSignalRBridgeName); + registry.Register(fleetBridge); + + var alertHub = resolver.GetService>(); + var alertBridge = system.ActorOf(AlertSignalRBridge.Props(alertHub), AlertSignalRBridgeName); + registry.Register(alertBridge); + + var scriptLogHub = resolver.GetService>(); + var scriptLogBridge = system.ActorOf(ScriptLogSignalRBridge.Props(scriptLogHub), ScriptLogSignalRBridgeName); + registry.Register(scriptLogBridge); }); return builder; } } -/// Marker key for lookup of the SignalR bridge actor. +/// Marker keys for lookup of the SignalR bridge actors. public sealed class FleetStatusSignalRBridgeKey { } +public sealed class AlertSignalRBridgeKey { } +public sealed class ScriptLogSignalRBridgeKey { } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogHub.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogHub.cs new file mode 100644 index 0000000..087ebe6 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogHub.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.SignalR; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; + +/// +/// Browser-facing script-log push channel. Subscribers receive +/// lines emitted by VirtualTagActor + +/// ScriptedAlarmActor as their hosted scripts log diagnostic output. Bridge: +/// ScriptLogSignalRBridge subscribes to the script-logs DPS topic and forwards +/// to every connected SignalR client. +/// +public sealed class ScriptLogHub : Hub +{ + public const string Endpoint = "/hubs/script-log"; + public const string MethodName = "scriptLogEntry"; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs new file mode 100644 index 0000000..1c3b26c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs @@ -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; + +/// +/// Akka actor that subscribes to the script-logs DistributedPubSub topic and forwards each +/// to every SignalR client connected to . +/// +public sealed class ScriptLogSignalRBridge : ReceiveActor +{ + public const string TopicName = "script-logs"; + + private readonly IHubContext _hub; + private readonly ILoggingAdapter _log = Context.GetLogger(); + + public static Props Props(IHubContext hub) => + Akka.Actor.Props.Create(() => new ScriptLogSignalRBridge(hub)); + + public ScriptLogSignalRBridge(IHubContext hub) + { + _hub = hub; + ReceiveAsync(ForwardAsync); + Receive(_ => { /* 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); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index 0f73137..03c6399 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -8,6 +8,7 @@ +