Files
ScadaBridge/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor
T
Joseph Doherty dcdf79afdc fix(dcl): format ArrayValue objects as comma-separated strings for display
ArrayValue from LmxProxy client was showing as type name in debug views.
Added ValueFormatter utility and NormalizeValue in LmxProxyDataConnection
to convert arrays at the adapter boundary. DateTime arrays remain as
"System.DateTime[]" due to server-side v1 string serialization.
2026-03-22 14:46:15 -04:00

325 lines
13 KiB
Plaintext

@page "/deployment/debug-view"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Instances
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.DebugView
@using ScadaLink.Commons.Messages.Streaming
@using ScadaLink.Commons.Types
@using ScadaLink.Commons.Types.Enums
@using ScadaLink.Communication
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject DebugStreamService DebugStreamService
@inject IJSRuntime JS
@implements IDisposable
<div class="container-fluid mt-3">
<h4 class="mb-3">Debug View</h4>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="row mb-3 g-2">
<div class="col-md-3">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_selectedSiteId" @bind:after="LoadInstancesForSite">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name (@site.SiteIdentifier)</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label small">Instance</label>
<select class="form-select form-select-sm" @bind="_selectedInstanceId" @bind:after="OnInstanceSelectionChanged">
<option value="0">Select instance...</option>
@foreach (var inst in _siteInstances)
{
<option value="@inst.Id">@inst.UniqueName (@inst.State)</option>
}
</select>
</div>
<div class="col-md-3 d-flex align-items-end gap-2">
@if (!_connected)
{
<button class="btn btn-primary btn-sm" @onclick="Connect"
disabled="@(_selectedInstanceId == 0 || _selectedSiteId == 0 || _connecting)">
@if (_connecting) { <span class="spinner-border spinner-border-sm me-1"></span> }
Connect
</button>
}
else
{
<button class="btn btn-outline-danger btn-sm" @onclick="Disconnect">Disconnect</button>
<span class="badge bg-success align-self-center">
<span class="spinner-grow spinner-grow-sm me-1" style="width: 0.5rem; height: 0.5rem;"></span>
Live
</span>
}
</div>
</div>
@if (_connected && _snapshot != null)
{
<div class="row">
@* Attribute Values *@
<div class="col-md-7">
<div class="card">
<div class="card-header py-2 d-flex justify-content-between">
<strong>Attribute Values</strong>
<small class="text-muted">@_attributeValues.Count values</small>
</div>
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-striped mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Attribute</th>
<th>Value</th>
<th>Quality</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
@foreach (var av in _attributeValues.Values.OrderBy(a => a.AttributeName))
{
<tr>
<td class="small">@av.AttributeName</td>
<td class="small font-monospace"><strong>@ValueFormatter.FormatDisplayValue(av.Value)</strong></td>
<td>
<span class="badge @(av.Quality == "Good" ? "bg-success" : "bg-warning text-dark")">@av.Quality</span>
</td>
<td class="small text-muted">@av.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@* Alarm States *@
<div class="col-md-5">
<div class="card">
<div class="card-header py-2 d-flex justify-content-between">
<strong>Alarm States</strong>
<small class="text-muted">@_alarmStates.Count alarms</small>
</div>
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-striped mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Alarm</th>
<th>State</th>
<th>Priority</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
@foreach (var alarm in _alarmStates.Values.OrderBy(a => a.AlarmName))
{
<tr class="@GetAlarmRowClass(alarm.State)">
<td class="small">@alarm.AlarmName</td>
<td>
<span class="badge @GetAlarmStateBadge(alarm.State)">@alarm.State</span>
</td>
<td class="small">@alarm.Priority</td>
<td class="small text-muted">@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="text-muted small mt-2">
Snapshot received: @_snapshot.SnapshotTimestamp.LocalDateTime.ToString("HH:mm:ss") |
@_attributeValues.Count attributes, @_alarmStates.Count alarms
</div>
}
else if (_connected)
{
<LoadingSpinner IsLoading="true" Message="Waiting for snapshot..." />
}
}
</div>
@code {
private List<Site> _sites = new();
private List<Instance> _siteInstances = new();
private int _selectedSiteId;
private int _selectedInstanceId;
private bool _loading = true;
private bool _connected;
private bool _connecting;
private DebugViewSnapshot? _snapshot;
private Dictionary<string, AttributeValueChanged> _attributeValues = new();
private Dictionary<string, AlarmStateChanged> _alarmStates = new();
private DebugStreamSession? _session;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
}
catch (Exception ex)
{
_toast.ShowError($"Failed to load sites: {ex.Message}");
}
_loading = false;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
var storedSiteId = await JS.InvokeAsync<string>("localStorage.getItem", "debugView.siteId");
var storedInstanceId = await JS.InvokeAsync<string>("localStorage.getItem", "debugView.instanceId");
if (!string.IsNullOrEmpty(storedSiteId) && int.TryParse(storedSiteId, out var siteId)
&& !string.IsNullOrEmpty(storedInstanceId) && int.TryParse(storedInstanceId, out var instanceId))
{
_selectedSiteId = siteId;
await LoadInstancesForSite();
_selectedInstanceId = instanceId;
StateHasChanged();
await Connect();
}
}
private async Task LoadInstancesForSite()
{
_siteInstances.Clear();
_selectedInstanceId = 0;
if (_selectedSiteId == 0) return;
try
{
_siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId))
.Where(i => i.State == InstanceState.Enabled)
.ToList();
}
catch (Exception ex)
{
_toast.ShowError($"Failed to load instances: {ex.Message}");
}
}
private void OnInstanceSelectionChanged()
{
// No-op; selection is tracked via _selectedInstanceId binding
}
private async Task Connect()
{
if (_selectedInstanceId == 0 || _selectedSiteId == 0) return;
_connecting = true;
try
{
var session = await DebugStreamService.StartStreamAsync(
_selectedInstanceId,
onEvent: evt =>
{
switch (evt)
{
case AttributeValueChanged av:
_attributeValues[av.AttributeName] = av;
_ = InvokeAsync(StateHasChanged);
break;
case AlarmStateChanged al:
_alarmStates[al.AlarmName] = al;
_ = InvokeAsync(StateHasChanged);
break;
}
},
onTerminated: () =>
{
_connected = false;
_session = null;
_ = InvokeAsync(() =>
{
_toast.ShowError("Debug stream terminated (site disconnected).");
StateHasChanged();
});
});
_session = session;
// Populate initial state from snapshot
_attributeValues.Clear();
foreach (var av in session.InitialSnapshot.AttributeValues)
_attributeValues[av.AttributeName] = av;
_alarmStates.Clear();
foreach (var al in session.InitialSnapshot.AlarmStates)
_alarmStates[al.AlarmName] = al;
_snapshot = session.InitialSnapshot;
_connected = true;
// Persist selection to localStorage for auto-reconnect on refresh
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.siteId", _selectedSiteId.ToString());
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.instanceId", _selectedInstanceId.ToString());
var instance = _siteInstances.FirstOrDefault(i => i.Id == _selectedInstanceId);
_toast.ShowSuccess($"Streaming {instance?.UniqueName ?? "instance"}");
}
catch (Exception ex)
{
_toast.ShowError($"Connect failed: {ex.Message}");
}
_connecting = false;
}
private async Task Disconnect()
{
if (_session != null)
{
DebugStreamService.StopStream(_session.SessionId);
_session = null;
}
// Clear persisted selection — user explicitly disconnected
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.siteId");
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceId");
_connected = false;
_snapshot = null;
_attributeValues.Clear();
_alarmStates.Clear();
}
private static string GetAlarmStateBadge(AlarmState state) => state switch
{
AlarmState.Active => "bg-danger",
AlarmState.Normal => "bg-success",
_ => "bg-secondary"
};
private static string GetAlarmRowClass(AlarmState state) => state switch
{
AlarmState.Active => "table-danger",
_ => ""
};
public void Dispose()
{
if (_session != null)
{
DebugStreamService.StopStream(_session.SessionId);
}
}
}