feat: add JoeAppEngine OPC UA nodes, fix DCL auto-reconnect and quality push

- Add JoeAppEngine folder to OPC UA nodes.json (BTCS, AlarmCntsBySeverity, Scheduler/ScanTime)
- Fix DataConnectionActor: capture Self in PreStart for use from non-actor threads,
  preventing Self.Tell failure in Disconnected event handler
- Implement InstanceActor.HandleConnectionQualityChanged to mark attributes Bad on disconnect
- Fix LmxFakeProxy TagMapper to serialize arrays as JSON instead of "System.Int32[]"
- Allow DataType and DataSourceReference updates in TemplateService.UpdateAttributeAsync
- Update test_infra_opcua.md with JoeAppEngine documentation
This commit is contained in:
Joseph Doherty
2026-03-19 13:27:54 -04:00
parent ffdda51990
commit 7740a3bcf9
70 changed files with 2684 additions and 541 deletions

View File

@@ -44,10 +44,14 @@
<label class="form-label small">To</label>
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterTo" />
</div>
<div class="col-md-2">
<div class="col-md-1">
<label class="form-label small">Keyword</label>
<input type="text" class="form-control form-control-sm" @bind="_filterKeyword" />
</div>
<div class="col-md-2">
<label class="form-label small">Instance</label>
<input type="text" class="form-control form-control-sm" @bind="_filterInstanceName" placeholder="Instance name" />
</div>
<div class="col-md-1 d-flex align-items-end">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
@if (_searching) { <span class="spinner-border spinner-border-sm"></span> }
@@ -111,6 +115,7 @@
private DateTime? _filterFrom;
private DateTime? _filterTo;
private string? _filterKeyword;
private string? _filterInstanceName;
private List<EventLogEntry>? _entries;
private bool _hasMore;
@@ -146,7 +151,7 @@
To: _filterTo.HasValue ? new DateTimeOffset(_filterTo.Value, TimeSpan.Zero) : null,
EventType: string.IsNullOrWhiteSpace(_filterEventType) ? null : _filterEventType.Trim(),
Severity: string.IsNullOrWhiteSpace(_filterSeverity) ? null : _filterSeverity,
InstanceId: null,
InstanceId: string.IsNullOrWhiteSpace(_filterInstanceName) ? null : _filterInstanceName.Trim(),
KeywordFilter: string.IsNullOrWhiteSpace(_filterKeyword) ? null : _filterKeyword.Trim(),
ContinuationToken: _continuationToken,
PageSize: 50,

View File

@@ -71,6 +71,10 @@
<span class="badge bg-danger me-2">Offline</span>
}
<strong>@siteId</strong>
@if (state.LatestReport?.NodeRole != null)
{
<span class="badge @(state.LatestReport.NodeRole == "Active" ? "bg-primary" : "bg-secondary") ms-2">@state.LatestReport.NodeRole</span>
}
</div>
<small class="text-muted">
Last report: @state.LastReportReceivedAt.LocalDateTime.ToString("HH:mm:ss") | Seq: @state.LastSequenceNumber

View File

@@ -70,9 +70,11 @@
<td class="small"><TimestampDisplay Value="@msg.LastAttemptTimestamp" /></td>
<td>
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
title="Retry message (not yet implemented)">Retry</button>
@onclick="() => RetryMessage(msg)" disabled="@_actionInProgress"
title="Retry message (move back to pending)">Retry</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
title="Discard message (not yet implemented)">Discard</button>
@onclick="() => DiscardMessage(msg)" disabled="@_actionInProgress"
title="Permanently discard message">Discard</button>
</td>
</tr>
}
@@ -102,6 +104,7 @@
private bool _searching;
private string? _errorMessage;
private bool _actionInProgress;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
@@ -150,4 +153,65 @@
}
_searching = false;
}
private async Task RetryMessage(ParkedMessageEntry msg)
{
_actionInProgress = true;
try
{
var request = new ParkedMessageRetryRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
SiteId: _selectedSiteId,
MessageId: msg.MessageId,
Timestamp: DateTimeOffset.UtcNow);
var response = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, request);
if (response.Success)
{
_toast.ShowSuccess($"Message {msg.MessageId[..Math.Min(12, msg.MessageId.Length)]} queued for retry.");
await FetchPage();
}
else
{
_toast.ShowError(response.ErrorMessage ?? "Retry failed.");
}
}
catch (Exception ex)
{
_toast.ShowError($"Retry failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DiscardMessage(ParkedMessageEntry msg)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Permanently discard message {msg.MessageId[..Math.Min(12, msg.MessageId.Length)]}? This cannot be undone.",
"Discard Parked Message");
if (!confirmed) return;
_actionInProgress = true;
try
{
var request = new ParkedMessageDiscardRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
SiteId: _selectedSiteId,
MessageId: msg.MessageId,
Timestamp: DateTimeOffset.UtcNow);
var response = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, request);
if (response.Success)
{
_toast.ShowSuccess($"Message {msg.MessageId[..Math.Min(12, msg.MessageId.Length)]} discarded.");
await FetchPage();
}
else
{
_toast.ShowError(response.ErrorMessage ?? "Discard failed.");
}
}
catch (Exception ex)
{
_toast.ShowError($"Discard failed: {ex.Message}");
}
_actionInProgress = false;
}
}