Files
ScadaBridge/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor
T
Joseph Doherty 2798b91fe1 Wire up debug view: route subscribe/unsubscribe through DeploymentManagerActor
DeploymentManagerActor now handles SubscribeDebugViewRequest and
UnsubscribeDebugViewRequest by forwarding to the appropriate Instance Actor.
This completes the debug view data flow from Central UI through to the site's
Instance Actor snapshot. Reduced refresh interval to 2s for responsiveness.
2026-03-17 10:55:47 -04:00

295 lines
12 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.Enums
@using ScadaLink.Communication
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject CommunicationService CommunicationService
@implements IDisposable
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Debug View</h4>
<div class="alert alert-info py-1 px-2 mb-0 small">
Debug view streams are lost on failover. Re-open if connection drops.
</div>
</div>
<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="_selectedInstanceName">
<option value="">Select instance...</option>
@foreach (var inst in _siteInstances)
{
<option value="@inst.UniqueName">@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="@(string.IsNullOrEmpty(_selectedInstanceName) || _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">Connected</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>@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 string _selectedInstanceName = string.Empty;
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 Timer? _refreshTimer;
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;
}
private async Task LoadInstancesForSite()
{
_siteInstances.Clear();
_selectedInstanceName = string.Empty;
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 async Task Connect()
{
if (string.IsNullOrEmpty(_selectedInstanceName) || _selectedSiteId == 0) return;
_connecting = true;
try
{
var site = _sites.FirstOrDefault(s => s.Id == _selectedSiteId);
if (site == null) return;
var request = new SubscribeDebugViewRequest(_selectedInstanceName, Guid.NewGuid().ToString("N"));
_snapshot = await CommunicationService.SubscribeDebugViewAsync(site.SiteIdentifier, request);
// Populate initial state from snapshot
_attributeValues.Clear();
foreach (var av in _snapshot.AttributeValues)
{
_attributeValues[av.AttributeName] = av;
}
_alarmStates.Clear();
foreach (var al in _snapshot.AlarmStates)
{
_alarmStates[al.AlarmName] = al;
}
_connected = true;
_toast.ShowSuccess($"Connected to {_selectedInstanceName}");
// Periodic refresh (simulating SignalR push by re-subscribing)
_refreshTimer = new Timer(async _ =>
{
try
{
var refreshRequest = new SubscribeDebugViewRequest(_selectedInstanceName, Guid.NewGuid().ToString("N"));
var newSnapshot = await CommunicationService.SubscribeDebugViewAsync(site.SiteIdentifier, refreshRequest);
foreach (var av in newSnapshot.AttributeValues)
_attributeValues[av.AttributeName] = av;
foreach (var al in newSnapshot.AlarmStates)
_alarmStates[al.AlarmName] = al;
_snapshot = newSnapshot;
await InvokeAsync(StateHasChanged);
}
catch
{
// Connection may have dropped
}
}, null, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
}
catch (Exception ex)
{
_toast.ShowError($"Connect failed: {ex.Message}");
}
_connecting = false;
}
private void Disconnect()
{
_refreshTimer?.Dispose();
_refreshTimer = null;
if (_connected && _selectedSiteId > 0 && !string.IsNullOrEmpty(_selectedInstanceName))
{
var site = _sites.FirstOrDefault(s => s.Id == _selectedSiteId);
if (site != null)
{
var request = new UnsubscribeDebugViewRequest(_selectedInstanceName, Guid.NewGuid().ToString("N"));
CommunicationService.UnsubscribeDebugView(site.SiteIdentifier, request);
}
}
_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()
{
_refreshTimer?.Dispose();
}
}