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

@@ -50,8 +50,8 @@
@if (_tab == "extsys") { @RenderExternalSystems() }
else if (_tab == "dbconn") { @RenderDbConnections() }
else if (_tab == "notif") { @RenderNotificationLists() }
else if (_tab == "inbound") { @RenderInboundApiMethods() }
else if (_tab == "notif") { @RenderNotificationLists() @RenderSmtpConfig() }
else if (_tab == "inbound") { @RenderInboundApiMethods() @RenderApiKeyMethodAssignments() }
}
</div>
@@ -66,6 +66,8 @@
private ExternalSystemDefinition? _editingExtSys;
private string _extSysName = "", _extSysUrl = "", _extSysAuth = "ApiKey";
private string? _extSysAuthConfig;
private int _extSysMaxRetries = 3;
private int _extSysRetryDelaySeconds = 5;
private string? _extSysFormError;
// Database Connections
@@ -73,8 +75,21 @@
private bool _showDbConnForm;
private DatabaseConnectionDefinition? _editingDbConn;
private string _dbConnName = "", _dbConnString = "";
private int _dbConnMaxRetries = 3;
private int _dbConnRetryDelaySeconds = 5;
private string? _dbConnFormError;
// SMTP Configuration
private List<SmtpConfiguration> _smtpConfigs = new();
private bool _showSmtpForm;
private SmtpConfiguration? _editingSmtp;
private string _smtpHost = "", _smtpFromAddress = "", _smtpAuthType = "OAuth2";
private int _smtpPort = 587;
private string? _smtpFormError;
// API Key list
private List<ApiKey> _apiKeys = new();
// Notification Lists
private List<NotificationList> _notificationLists = new();
private bool _showNotifForm;
@@ -123,6 +138,8 @@
}
_apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
_smtpConfigs = (await NotificationRepository.GetAllSmtpConfigurationsAsync()).ToList();
_apiKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
}
catch (Exception ex) { _errorMessage = ex.Message; }
_loading = false;
@@ -144,8 +161,10 @@
<div class="col-md-3"><label class="form-label small">Endpoint URL</label><input type="text" class="form-control form-control-sm" @bind="_extSysUrl" /></div>
<div class="col-md-2"><label class="form-label small">Auth Type</label>
<select class="form-select form-select-sm" @bind="_extSysAuth"><option>ApiKey</option><option>BasicAuth</option></select></div>
<div class="col-md-3"><label class="form-label small">Auth Config (JSON)</label><input type="text" class="form-control form-control-sm" @bind="_extSysAuthConfig" /></div>
<div class="col-md-2">
<div class="col-md-2"><label class="form-label small">Auth Config (JSON)</label><input type="text" class="form-control form-control-sm" @bind="_extSysAuthConfig" /></div>
<div class="col-md-1"><label class="form-label small">Max Retries</label><input type="number" class="form-control form-control-sm" @bind="_extSysMaxRetries" min="0" /></div>
<div class="col-md-1"><label class="form-label small">Retry Delay (s)</label><input type="number" class="form-control form-control-sm" @bind="_extSysRetryDelaySeconds" min="0" /></div>
<div class="col-md-1">
<button class="btn btn-success btn-sm me-1" @onclick="SaveExtSys">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showExtSysForm = false">Cancel</button></div>
</div>
@@ -154,14 +173,15 @@
}
<table class="table table-sm table-striped">
<thead class="table-dark"><tr><th>Name</th><th>URL</th><th>Auth</th><th style="width:120px;">Actions</th></tr></thead>
<thead class="table-dark"><tr><th>Name</th><th>URL</th><th>Auth</th><th>Retries</th><th>Delay</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@foreach (var es in _externalSystems)
{
<tr>
<td>@es.Name</td><td class="small">@es.EndpointUrl</td><td><span class="badge bg-secondary">@es.AuthType</span></td>
<td class="small">@es.MaxRetries</td><td class="small">@es.RetryDelay.TotalSeconds s</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingExtSys = es; _extSysName = es.Name; _extSysUrl = es.EndpointUrl; _extSysAuth = es.AuthType; _extSysAuthConfig = es.AuthConfiguration; _showExtSysForm = true; }">Edit</button>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingExtSys = es; _extSysName = es.Name; _extSysUrl = es.EndpointUrl; _extSysAuth = es.AuthType; _extSysAuthConfig = es.AuthConfiguration; _extSysMaxRetries = es.MaxRetries; _extSysRetryDelaySeconds = (int)es.RetryDelay.TotalSeconds; _showExtSysForm = true; }">Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteExtSys(es)">Delete</button>
</td>
</tr>
@@ -177,6 +197,8 @@
_extSysName = _extSysUrl = string.Empty;
_extSysAuth = "ApiKey";
_extSysAuthConfig = null;
_extSysMaxRetries = 3;
_extSysRetryDelaySeconds = 5;
_extSysFormError = null;
}
@@ -186,8 +208,8 @@
if (string.IsNullOrWhiteSpace(_extSysName) || string.IsNullOrWhiteSpace(_extSysUrl)) { _extSysFormError = "Name and URL required."; return; }
try
{
if (_editingExtSys != null) { _editingExtSys.Name = _extSysName.Trim(); _editingExtSys.EndpointUrl = _extSysUrl.Trim(); _editingExtSys.AuthType = _extSysAuth; _editingExtSys.AuthConfiguration = _extSysAuthConfig?.Trim(); await ExternalSystemRepository.UpdateExternalSystemAsync(_editingExtSys); }
else { var es = new ExternalSystemDefinition(_extSysName.Trim(), _extSysUrl.Trim(), _extSysAuth) { AuthConfiguration = _extSysAuthConfig?.Trim() }; await ExternalSystemRepository.AddExternalSystemAsync(es); }
if (_editingExtSys != null) { _editingExtSys.Name = _extSysName.Trim(); _editingExtSys.EndpointUrl = _extSysUrl.Trim(); _editingExtSys.AuthType = _extSysAuth; _editingExtSys.AuthConfiguration = _extSysAuthConfig?.Trim(); _editingExtSys.MaxRetries = _extSysMaxRetries; _editingExtSys.RetryDelay = TimeSpan.FromSeconds(_extSysRetryDelaySeconds); await ExternalSystemRepository.UpdateExternalSystemAsync(_editingExtSys); }
else { var es = new ExternalSystemDefinition(_extSysName.Trim(), _extSysUrl.Trim(), _extSysAuth) { AuthConfiguration = _extSysAuthConfig?.Trim(), MaxRetries = _extSysMaxRetries, RetryDelay = TimeSpan.FromSeconds(_extSysRetryDelaySeconds) }; await ExternalSystemRepository.AddExternalSystemAsync(es); }
await ExternalSystemRepository.SaveChangesAsync(); _showExtSysForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync();
}
catch (Exception ex) { _extSysFormError = ex.Message; }
@@ -205,7 +227,7 @@
{
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0">Database Connections</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showDbConnForm = true; _editingDbConn = null; _dbConnName = _dbConnString = string.Empty; _dbConnFormError = null; }">Add</button>
<button class="btn btn-primary btn-sm" @onclick="() => { _showDbConnForm = true; _editingDbConn = null; _dbConnName = _dbConnString = string.Empty; _dbConnMaxRetries = 3; _dbConnRetryDelaySeconds = 5; _dbConnFormError = null; }">Add</button>
</div>
@if (_showDbConnForm)
@@ -213,7 +235,9 @@
<div class="card mb-2"><div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3"><label class="form-label small">Name</label><input type="text" class="form-control form-control-sm" @bind="_dbConnName" /></div>
<div class="col-md-6"><label class="form-label small">Connection String</label><input type="text" class="form-control form-control-sm" @bind="_dbConnString" /></div>
<div class="col-md-4"><label class="form-label small">Connection String</label><input type="text" class="form-control form-control-sm" @bind="_dbConnString" /></div>
<div class="col-md-1"><label class="form-label small">Max Retries</label><input type="number" class="form-control form-control-sm" @bind="_dbConnMaxRetries" min="0" /></div>
<div class="col-md-1"><label class="form-label small">Retry Delay (s)</label><input type="number" class="form-control form-control-sm" @bind="_dbConnRetryDelaySeconds" min="0" /></div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveDbConn">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showDbConnForm = false">Cancel</button></div>
@@ -223,14 +247,15 @@
}
<table class="table table-sm table-striped">
<thead class="table-dark"><tr><th>Name</th><th>Connection String</th><th style="width:120px;">Actions</th></tr></thead>
<thead class="table-dark"><tr><th>Name</th><th>Connection String</th><th>Retries</th><th>Delay</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@foreach (var dc in _dbConnections)
{
<tr>
<td>@dc.Name</td><td class="small text-muted text-truncate" style="max-width:400px;">@dc.ConnectionString</td>
<td class="small">@dc.MaxRetries</td><td class="small">@dc.RetryDelay.TotalSeconds s</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingDbConn = dc; _dbConnName = dc.Name; _dbConnString = dc.ConnectionString; _showDbConnForm = true; }">Edit</button>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingDbConn = dc; _dbConnName = dc.Name; _dbConnString = dc.ConnectionString; _dbConnMaxRetries = dc.MaxRetries; _dbConnRetryDelaySeconds = (int)dc.RetryDelay.TotalSeconds; _showDbConnForm = true; }">Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteDbConn(dc)">Delete</button>
</td>
</tr>
@@ -245,8 +270,8 @@
if (string.IsNullOrWhiteSpace(_dbConnName) || string.IsNullOrWhiteSpace(_dbConnString)) { _dbConnFormError = "Name and connection string required."; return; }
try
{
if (_editingDbConn != null) { _editingDbConn.Name = _dbConnName.Trim(); _editingDbConn.ConnectionString = _dbConnString.Trim(); await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_editingDbConn); }
else { var dc = new DatabaseConnectionDefinition(_dbConnName.Trim(), _dbConnString.Trim()); await ExternalSystemRepository.AddDatabaseConnectionAsync(dc); }
if (_editingDbConn != null) { _editingDbConn.Name = _dbConnName.Trim(); _editingDbConn.ConnectionString = _dbConnString.Trim(); _editingDbConn.MaxRetries = _dbConnMaxRetries; _editingDbConn.RetryDelay = TimeSpan.FromSeconds(_dbConnRetryDelaySeconds); await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_editingDbConn); }
else { var dc = new DatabaseConnectionDefinition(_dbConnName.Trim(), _dbConnString.Trim()) { MaxRetries = _dbConnMaxRetries, RetryDelay = TimeSpan.FromSeconds(_dbConnRetryDelaySeconds) }; await ExternalSystemRepository.AddDatabaseConnectionAsync(dc); }
await ExternalSystemRepository.SaveChangesAsync(); _showDbConnForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync();
}
catch (Exception ex) { _dbConnFormError = ex.Message; }
@@ -434,4 +459,127 @@
try { await InboundApiRepository.DeleteApiMethodAsync(m.Id); await InboundApiRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
// ==== SMTP Configuration ====
private RenderFragment RenderSmtpConfig() => __builder =>
{
<hr class="my-3" />
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0">SMTP Configuration</h6>
@if (_smtpConfigs.Count == 0)
{
<button class="btn btn-primary btn-sm" @onclick="ShowSmtpAddForm">Add SMTP Config</button>
}
</div>
@if (_showSmtpForm)
{
<div class="card mb-2"><div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3"><label class="form-label small">Host</label><input type="text" class="form-control form-control-sm" @bind="_smtpHost" /></div>
<div class="col-md-1"><label class="form-label small">Port</label><input type="number" class="form-control form-control-sm" @bind="_smtpPort" /></div>
<div class="col-md-2"><label class="form-label small">Auth Type</label>
<select class="form-select form-select-sm" @bind="_smtpAuthType"><option>OAuth2</option><option>Basic</option></select></div>
<div class="col-md-3"><label class="form-label small">From Address</label><input type="email" class="form-control form-control-sm" @bind="_smtpFromAddress" /></div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSmtpConfig">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showSmtpForm = false">Cancel</button></div>
</div>
@if (_smtpFormError != null) { <div class="text-danger small mt-1">@_smtpFormError</div> }
</div></div>
}
@foreach (var smtp in _smtpConfigs)
{
<div class="card mb-2">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<span class="small">
<strong>@smtp.Host</strong>:@smtp.Port |
Auth: <span class="badge bg-secondary">@smtp.AuthType</span> |
From: @smtp.FromAddress
</span>
<button class="btn btn-outline-primary btn-sm py-0 px-1" @onclick="() => { _editingSmtp = smtp; _smtpHost = smtp.Host; _smtpPort = smtp.Port; _smtpAuthType = smtp.AuthType; _smtpFromAddress = smtp.FromAddress; _showSmtpForm = true; }">Edit</button>
</div>
</div>
</div>
}
};
private void ShowSmtpAddForm()
{
_showSmtpForm = true;
_editingSmtp = null;
_smtpHost = string.Empty;
_smtpPort = 587;
_smtpAuthType = "OAuth2";
_smtpFromAddress = string.Empty;
_smtpFormError = null;
}
private async Task SaveSmtpConfig()
{
_smtpFormError = null;
if (string.IsNullOrWhiteSpace(_smtpHost) || string.IsNullOrWhiteSpace(_smtpFromAddress)) { _smtpFormError = "Host and From Address required."; return; }
try
{
if (_editingSmtp != null)
{
_editingSmtp.Host = _smtpHost.Trim();
_editingSmtp.Port = _smtpPort;
_editingSmtp.AuthType = _smtpAuthType;
_editingSmtp.FromAddress = _smtpFromAddress.Trim();
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
}
else
{
var smtp = new SmtpConfiguration(_smtpHost.Trim(), _smtpAuthType, _smtpFromAddress.Trim()) { Port = _smtpPort };
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
}
await NotificationRepository.SaveChangesAsync();
_showSmtpForm = false;
_toast.ShowSuccess("SMTP configuration saved.");
await LoadAllAsync();
}
catch (Exception ex) { _smtpFormError = ex.Message; }
}
// ==== API Key → Method Assignments ====
private RenderFragment RenderApiKeyMethodAssignments() => __builder =>
{
<hr class="my-3" />
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0">API Keys</h6>
</div>
<table class="table table-sm table-striped">
<thead class="table-dark"><tr><th>Key Name</th><th>Enabled</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@foreach (var key in _apiKeys)
{
<tr>
<td>@key.Name</td>
<td><span class="badge @(key.IsEnabled ? "bg-success" : "bg-secondary")">@(key.IsEnabled ? "Enabled" : "Disabled")</span></td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1" @onclick="() => ToggleApiKeyEnabled(key)">
@(key.IsEnabled ? "Disable" : "Enable")
</button>
</td>
</tr>
}
</tbody>
</table>
};
private async Task ToggleApiKeyEnabled(ApiKey key)
{
try
{
key.IsEnabled = !key.IsEnabled;
await InboundApiRepository.UpdateApiKeyAsync(key);
await InboundApiRepository.SaveChangesAsync();
_toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}.");
}
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
}

View File

@@ -476,9 +476,10 @@
_validationResult = null;
try
{
// Use the ValidationService for on-demand validation
// Use the full validation pipeline via TemplateService
// This performs flattening, collision detection, script compilation,
// trigger reference validation, and connection binding checks
var validationService = new ValidationService();
// Build a minimal flattened config from the template's direct members for validation
var flatConfig = new Commons.Types.Flattening.FlattenedConfiguration
{
InstanceUniqueName = $"validation-{_selectedTemplate.Name}",
@@ -511,6 +512,17 @@
}).ToList()
};
_validationResult = validationService.Validate(flatConfig);
// Also check for naming collisions across the inheritance/composition graph
var collisions = await TemplateService.DetectCollisionsAsync(_selectedTemplate.Id);
if (collisions.Count > 0)
{
var collisionErrors = collisions.Select(c =>
Commons.Types.Flattening.ValidationEntry.Error(
Commons.Types.Flattening.ValidationCategory.NamingCollision, c)).ToArray();
var collisionResult = new Commons.Types.Flattening.ValidationResult { Errors = collisionErrors };
_validationResult = Commons.Types.Flattening.ValidationResult.Merge(_validationResult, collisionResult);
}
}
catch (Exception ex)
{