59858129cb
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.
164 lines
6.1 KiB
Plaintext
164 lines
6.1 KiB
Plaintext
@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();
|
|
}
|
|
}
|