feat: achieve CLI parity with Central UI

Add 33 new management message records, ManagementActor handlers, and CLI
commands to close all functionality gaps between the Central UI and the
Management CLI. New capabilities include:

- Template member CRUD (attributes, alarms, scripts, compositions)
- Shared script CRUD
- Database connection definition CRUD
- Inbound API method CRUD
- LDAP scope rule management
- API key enable/disable
- Area update
- Remote event log and parked message queries
- Missing get/update commands for templates, sites, instances, data
  connections, external systems, notifications, and SMTP config

Includes 12 new ManagementActor unit tests covering authorization,
happy-path queries, and error handling. Updates CLI README and component
design documents (Component-CLI.md, Component-ManagementService.md).
This commit is contained in:
Joseph Doherty
2026-03-18 01:21:20 -04:00
parent b2385709f8
commit c63fb1c4a6
24 changed files with 2500 additions and 15 deletions

View File

@@ -6,11 +6,14 @@ using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Entities.Instances;
using ScadaLink.Commons.Entities.Scripts;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Entities.Security;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Management;
using ScadaLink.Commons.Messages.RemoteQuery;
using ScadaLink.DeploymentManager;
using ScadaLink.HealthMonitoring;
using ScadaLink.Communication;
@@ -76,7 +79,9 @@ public class ManagementActor : ReceiveActor
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
or ListRoleMappingsCommand or CreateRoleMappingCommand
or UpdateRoleMappingCommand or DeleteRoleMappingCommand
or ListApiKeysCommand or CreateApiKeyCommand or DeleteApiKeyCommand => "Admin",
or ListApiKeysCommand or CreateApiKeyCommand or DeleteApiKeyCommand
or UpdateApiKeyCommand
or ListScopeRulesCommand or AddScopeRuleCommand or DeleteScopeRuleCommand => "Admin",
// Design operations
CreateAreaCommand or DeleteAreaCommand
@@ -90,7 +95,15 @@ public class ManagementActor : ReceiveActor
or CreateDataConnectionCommand or UpdateDataConnectionCommand
or DeleteDataConnectionCommand
or AssignDataConnectionToSiteCommand
or UnassignDataConnectionFromSiteCommand => "Design",
or UnassignDataConnectionFromSiteCommand
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand
or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
or AddTemplateCompositionCommand or DeleteTemplateCompositionCommand
or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
or UpdateAreaCommand => "Design",
// Deployment operations
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
@@ -114,6 +127,19 @@ public class ManagementActor : ReceiveActor
DeleteTemplateCommand cmd => await HandleDeleteTemplate(sp, cmd, user),
ValidateTemplateCommand cmd => await HandleValidateTemplate(sp, cmd),
// Template members
AddTemplateAttributeCommand cmd => await HandleAddAttribute(sp, cmd, user),
UpdateTemplateAttributeCommand cmd => await HandleUpdateAttribute(sp, cmd, user),
DeleteTemplateAttributeCommand cmd => await HandleDeleteAttribute(sp, cmd, user),
AddTemplateAlarmCommand cmd => await HandleAddAlarm(sp, cmd, user),
UpdateTemplateAlarmCommand cmd => await HandleUpdateAlarm(sp, cmd, user),
DeleteTemplateAlarmCommand cmd => await HandleDeleteAlarm(sp, cmd, user),
AddTemplateScriptCommand cmd => await HandleAddScript(sp, cmd, user),
UpdateTemplateScriptCommand cmd => await HandleUpdateScript(sp, cmd, user),
DeleteTemplateScriptCommand cmd => await HandleDeleteScript(sp, cmd, user),
AddTemplateCompositionCommand cmd => await HandleAddComposition(sp, cmd, user),
DeleteTemplateCompositionCommand cmd => await HandleDeleteComposition(sp, cmd, user),
// Instances
ListInstancesCommand cmd => await HandleListInstances(sp, cmd),
GetInstanceCommand cmd => await HandleGetInstance(sp, cmd),
@@ -133,6 +159,7 @@ public class ManagementActor : ReceiveActor
ListAreasCommand cmd => await HandleListAreas(sp, cmd),
CreateAreaCommand cmd => await HandleCreateArea(sp, cmd),
DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd),
UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd),
// Data Connections
ListDataConnectionsCommand => await HandleListDataConnections(sp),
@@ -159,6 +186,27 @@ public class ManagementActor : ReceiveActor
ListSmtpConfigsCommand => await HandleListSmtpConfigs(sp),
UpdateSmtpConfigCommand cmd => await HandleUpdateSmtpConfig(sp, cmd),
// Shared Scripts
ListSharedScriptsCommand => await HandleListSharedScripts(sp),
GetSharedScriptCommand cmd => await HandleGetSharedScript(sp, cmd),
CreateSharedScriptCommand cmd => await HandleCreateSharedScript(sp, cmd, user),
UpdateSharedScriptCommand cmd => await HandleUpdateSharedScript(sp, cmd, user),
DeleteSharedScriptCommand cmd => await HandleDeleteSharedScript(sp, cmd, user),
// Database Connections (External System)
ListDatabaseConnectionsCommand => await HandleListDatabaseConnections(sp),
GetDatabaseConnectionCommand cmd => await HandleGetDatabaseConnection(sp, cmd),
CreateDatabaseConnectionDefCommand cmd => await HandleCreateDatabaseConnection(sp, cmd),
UpdateDatabaseConnectionDefCommand cmd => await HandleUpdateDatabaseConnection(sp, cmd),
DeleteDatabaseConnectionDefCommand cmd => await HandleDeleteDatabaseConnection(sp, cmd),
// Inbound API Methods
ListApiMethodsCommand => await HandleListApiMethods(sp),
GetApiMethodCommand cmd => await HandleGetApiMethod(sp, cmd),
CreateApiMethodCommand cmd => await HandleCreateApiMethod(sp, cmd),
UpdateApiMethodCommand cmd => await HandleUpdateApiMethod(sp, cmd),
DeleteApiMethodCommand cmd => await HandleDeleteApiMethod(sp, cmd),
// Security
ListRoleMappingsCommand => await HandleListRoleMappings(sp),
CreateRoleMappingCommand cmd => await HandleCreateRoleMapping(sp, cmd),
@@ -167,6 +215,10 @@ public class ManagementActor : ReceiveActor
ListApiKeysCommand => await HandleListApiKeys(sp),
CreateApiKeyCommand cmd => await HandleCreateApiKey(sp, cmd),
DeleteApiKeyCommand cmd => await HandleDeleteApiKey(sp, cmd),
UpdateApiKeyCommand cmd => await HandleUpdateApiKey(sp, cmd),
ListScopeRulesCommand cmd => await HandleListScopeRules(sp, cmd),
AddScopeRuleCommand cmd => await HandleAddScopeRule(sp, cmd),
DeleteScopeRuleCommand cmd => await HandleDeleteScopeRule(sp, cmd),
// Deployments
MgmtDeployArtifactsCommand cmd => await HandleDeployArtifacts(sp, cmd, user),
@@ -179,6 +231,10 @@ public class ManagementActor : ReceiveActor
GetHealthSummaryCommand => HandleGetHealthSummary(sp),
GetSiteHealthCommand cmd => HandleGetSiteHealth(sp, cmd),
// Remote Queries
QueryEventLogsCommand cmd => await HandleQueryEventLogs(sp, cmd),
QueryParkedMessagesCommand cmd => await HandleQueryParkedMessages(sp, cmd),
_ => throw new NotSupportedException($"Unknown command type: {command.GetType().Name}")
};
}
@@ -705,4 +761,348 @@ public class ManagementActor : ReceiveActor
var aggregator = sp.GetRequiredService<ICentralHealthAggregator>();
return aggregator.GetSiteState(cmd.SiteIdentifier);
}
// ========================================================================
// Template member handlers
// ========================================================================
private static async Task<object?> HandleAddAttribute(IServiceProvider sp, AddTemplateAttributeCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var attr = new TemplateAttribute(cmd.Name)
{
DataType = Enum.Parse<ScadaLink.Commons.Types.Enums.DataType>(cmd.DataType, ignoreCase: true),
Value = cmd.Value,
Description = cmd.Description,
DataSourceReference = cmd.DataSourceReference,
IsLocked = cmd.IsLocked
};
var result = await svc.AddAttributeAsync(cmd.TemplateId, attr, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleUpdateAttribute(IServiceProvider sp, UpdateTemplateAttributeCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var attr = new TemplateAttribute(cmd.Name)
{
DataType = Enum.Parse<ScadaLink.Commons.Types.Enums.DataType>(cmd.DataType, ignoreCase: true),
Value = cmd.Value,
Description = cmd.Description,
DataSourceReference = cmd.DataSourceReference,
IsLocked = cmd.IsLocked
};
var result = await svc.UpdateAttributeAsync(cmd.AttributeId, attr, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleDeleteAttribute(IServiceProvider sp, DeleteTemplateAttributeCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var result = await svc.DeleteAttributeAsync(cmd.AttributeId, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleAddAlarm(IServiceProvider sp, AddTemplateAlarmCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var alarm = new TemplateAlarm(cmd.Name)
{
TriggerType = Enum.Parse<ScadaLink.Commons.Types.Enums.AlarmTriggerType>(cmd.TriggerType, ignoreCase: true),
PriorityLevel = cmd.PriorityLevel,
Description = cmd.Description,
TriggerConfiguration = cmd.TriggerConfiguration,
IsLocked = cmd.IsLocked
};
var result = await svc.AddAlarmAsync(cmd.TemplateId, alarm, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleUpdateAlarm(IServiceProvider sp, UpdateTemplateAlarmCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var alarm = new TemplateAlarm(cmd.Name)
{
TriggerType = Enum.Parse<ScadaLink.Commons.Types.Enums.AlarmTriggerType>(cmd.TriggerType, ignoreCase: true),
PriorityLevel = cmd.PriorityLevel,
Description = cmd.Description,
TriggerConfiguration = cmd.TriggerConfiguration,
IsLocked = cmd.IsLocked
};
var result = await svc.UpdateAlarmAsync(cmd.AlarmId, alarm, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleDeleteAlarm(IServiceProvider sp, DeleteTemplateAlarmCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var result = await svc.DeleteAlarmAsync(cmd.AlarmId, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleAddScript(IServiceProvider sp, AddTemplateScriptCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var script = new TemplateScript(cmd.Name, cmd.Code)
{
TriggerType = cmd.TriggerType,
TriggerConfiguration = cmd.TriggerConfiguration,
IsLocked = cmd.IsLocked
};
var result = await svc.AddScriptAsync(cmd.TemplateId, script, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleUpdateScript(IServiceProvider sp, UpdateTemplateScriptCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var script = new TemplateScript(cmd.Name, cmd.Code)
{
TriggerType = cmd.TriggerType,
TriggerConfiguration = cmd.TriggerConfiguration,
IsLocked = cmd.IsLocked
};
var result = await svc.UpdateScriptAsync(cmd.ScriptId, script, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleDeleteScript(IServiceProvider sp, DeleteTemplateScriptCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var result = await svc.DeleteScriptAsync(cmd.ScriptId, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleAddComposition(IServiceProvider sp, AddTemplateCompositionCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var result = await svc.AddCompositionAsync(cmd.TemplateId, cmd.ComposedTemplateId, cmd.InstanceName, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleDeleteComposition(IServiceProvider sp, DeleteTemplateCompositionCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var result = await svc.DeleteCompositionAsync(cmd.CompositionId, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
// ========================================================================
// Shared Script handlers
// ========================================================================
private static async Task<object?> HandleListSharedScripts(IServiceProvider sp)
{
var svc = sp.GetRequiredService<SharedScriptService>();
return await svc.GetAllSharedScriptsAsync();
}
private static async Task<object?> HandleGetSharedScript(IServiceProvider sp, GetSharedScriptCommand cmd)
{
var svc = sp.GetRequiredService<SharedScriptService>();
return await svc.GetSharedScriptByIdAsync(cmd.SharedScriptId);
}
private static async Task<object?> HandleCreateSharedScript(IServiceProvider sp, CreateSharedScriptCommand cmd, string user)
{
var svc = sp.GetRequiredService<SharedScriptService>();
var result = await svc.CreateSharedScriptAsync(cmd.Name, cmd.Code, cmd.ParameterDefinitions, cmd.ReturnDefinition, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleUpdateSharedScript(IServiceProvider sp, UpdateSharedScriptCommand cmd, string user)
{
var svc = sp.GetRequiredService<SharedScriptService>();
var result = await svc.UpdateSharedScriptAsync(cmd.SharedScriptId, cmd.Code, cmd.ParameterDefinitions, cmd.ReturnDefinition, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleDeleteSharedScript(IServiceProvider sp, DeleteSharedScriptCommand cmd, string user)
{
var svc = sp.GetRequiredService<SharedScriptService>();
var result = await svc.DeleteSharedScriptAsync(cmd.SharedScriptId, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
// ========================================================================
// Database Connection Definition handlers
// ========================================================================
private static async Task<object?> HandleListDatabaseConnections(IServiceProvider sp)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
return await repo.GetAllDatabaseConnectionsAsync();
}
private static async Task<object?> HandleGetDatabaseConnection(IServiceProvider sp, GetDatabaseConnectionCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
return await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId);
}
private static async Task<object?> HandleCreateDatabaseConnection(IServiceProvider sp, CreateDatabaseConnectionDefCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
var def = new DatabaseConnectionDefinition(cmd.Name, cmd.ConnectionString);
await repo.AddDatabaseConnectionAsync(def);
await repo.SaveChangesAsync();
return def;
}
private static async Task<object?> HandleUpdateDatabaseConnection(IServiceProvider sp, UpdateDatabaseConnectionDefCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
var def = await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId)
?? throw new InvalidOperationException($"DatabaseConnection with ID {cmd.DatabaseConnectionId} not found.");
def.Name = cmd.Name;
def.ConnectionString = cmd.ConnectionString;
await repo.UpdateDatabaseConnectionAsync(def);
await repo.SaveChangesAsync();
return def;
}
private static async Task<object?> HandleDeleteDatabaseConnection(IServiceProvider sp, DeleteDatabaseConnectionDefCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
await repo.DeleteDatabaseConnectionAsync(cmd.DatabaseConnectionId);
await repo.SaveChangesAsync();
return true;
}
// ========================================================================
// Inbound API Method handlers
// ========================================================================
private static async Task<object?> HandleListApiMethods(IServiceProvider sp)
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
return await repo.GetAllApiMethodsAsync();
}
private static async Task<object?> HandleGetApiMethod(IServiceProvider sp, GetApiMethodCommand cmd)
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
return await repo.GetApiMethodByIdAsync(cmd.ApiMethodId);
}
private static async Task<object?> HandleCreateApiMethod(IServiceProvider sp, CreateApiMethodCommand cmd)
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
var method = new ApiMethod(cmd.Name, cmd.Script)
{
TimeoutSeconds = cmd.TimeoutSeconds,
ParameterDefinitions = cmd.ParameterDefinitions,
ReturnDefinition = cmd.ReturnDefinition
};
await repo.AddApiMethodAsync(method);
await repo.SaveChangesAsync();
return method;
}
private static async Task<object?> HandleUpdateApiMethod(IServiceProvider sp, UpdateApiMethodCommand cmd)
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId)
?? throw new InvalidOperationException($"ApiMethod with ID {cmd.ApiMethodId} not found.");
method.Script = cmd.Script;
method.TimeoutSeconds = cmd.TimeoutSeconds;
method.ParameterDefinitions = cmd.ParameterDefinitions;
method.ReturnDefinition = cmd.ReturnDefinition;
await repo.UpdateApiMethodAsync(method);
await repo.SaveChangesAsync();
return method;
}
private static async Task<object?> HandleDeleteApiMethod(IServiceProvider sp, DeleteApiMethodCommand cmd)
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
await repo.DeleteApiMethodAsync(cmd.ApiMethodId);
await repo.SaveChangesAsync();
return true;
}
// ========================================================================
// Additional Security handlers (API key update, scope rules)
// ========================================================================
private static async Task<object?> HandleUpdateApiKey(IServiceProvider sp, UpdateApiKeyCommand cmd)
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
var key = await repo.GetApiKeyByIdAsync(cmd.ApiKeyId)
?? throw new InvalidOperationException($"ApiKey with ID {cmd.ApiKeyId} not found.");
key.IsEnabled = cmd.IsEnabled;
await repo.UpdateApiKeyAsync(key);
await repo.SaveChangesAsync();
return key;
}
private static async Task<object?> HandleListScopeRules(IServiceProvider sp, ListScopeRulesCommand cmd)
{
var repo = sp.GetRequiredService<ISecurityRepository>();
return await repo.GetScopeRulesForMappingAsync(cmd.MappingId);
}
private static async Task<object?> HandleAddScopeRule(IServiceProvider sp, AddScopeRuleCommand cmd)
{
var repo = sp.GetRequiredService<ISecurityRepository>();
var rule = new SiteScopeRule { LdapGroupMappingId = cmd.MappingId, SiteId = cmd.SiteId };
await repo.AddScopeRuleAsync(rule);
await repo.SaveChangesAsync();
return rule;
}
private static async Task<object?> HandleDeleteScopeRule(IServiceProvider sp, DeleteScopeRuleCommand cmd)
{
var repo = sp.GetRequiredService<ISecurityRepository>();
await repo.DeleteScopeRuleAsync(cmd.ScopeRuleId);
await repo.SaveChangesAsync();
return true;
}
// ========================================================================
// Area update handler
// ========================================================================
private static async Task<object?> HandleUpdateArea(IServiceProvider sp, UpdateAreaCommand cmd)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var area = await repo.GetAreaByIdAsync(cmd.AreaId)
?? throw new InvalidOperationException($"Area with ID {cmd.AreaId} not found.");
area.Name = cmd.Name;
await repo.UpdateAreaAsync(area);
await repo.SaveChangesAsync();
return area;
}
// ========================================================================
// Remote Query handlers
// ========================================================================
private static async Task<object?> HandleQueryEventLogs(IServiceProvider sp, QueryEventLogsCommand cmd)
{
var commService = sp.GetRequiredService<CommunicationService>();
var request = new EventLogQueryRequest(
Guid.NewGuid().ToString("N"),
cmd.SiteIdentifier,
cmd.From, cmd.To,
cmd.EventType, cmd.Severity,
null, // InstanceId
cmd.Keyword,
null, // ContinuationToken
cmd.PageSize,
DateTimeOffset.UtcNow);
return await commService.QueryEventLogsAsync(cmd.SiteIdentifier, request);
}
private static async Task<object?> HandleQueryParkedMessages(IServiceProvider sp, QueryParkedMessagesCommand cmd)
{
var commService = sp.GetRequiredService<CommunicationService>();
var request = new ParkedMessageQueryRequest(
Guid.NewGuid().ToString("N"),
cmd.SiteIdentifier,
cmd.Page,
cmd.PageSize,
DateTimeOffset.UtcNow);
return await commService.QueryParkedMessagesAsync(cmd.SiteIdentifier, request);
}
}