389 lines
17 KiB
C#
389 lines
17 KiB
C#
using ScadaLink.Commons.Entities.Instances;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Messages.Management;
|
|
using ScadaLink.Commons.Types;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
using ScadaLink.TemplateEngine;
|
|
|
|
namespace ScadaLink.TemplateEngine.Services;
|
|
|
|
/// <summary>
|
|
/// Instance CRUD operations.
|
|
/// - Create instance from template at site
|
|
/// - Assign to area
|
|
/// - Override non-locked attribute values
|
|
/// - Cannot add or remove attributes (only override existing ones)
|
|
/// - Per-attribute connection binding (bulk assignment support)
|
|
/// - Enabled/disabled state. Concurrent edits are last-write-wins — there is no
|
|
/// version token or conflict detection on instance state, matching the design
|
|
/// decision (Component-TemplateEngine.md: "Concurrent editing uses
|
|
/// last-write-wins — no pessimistic locking or conflict detection"). Optimistic
|
|
/// concurrency in the system applies to deployment status records, not here.
|
|
/// - Audit logging
|
|
/// </summary>
|
|
public class InstanceService
|
|
{
|
|
private readonly ITemplateEngineRepository _repository;
|
|
private readonly IAuditService _auditService;
|
|
|
|
public InstanceService(ITemplateEngineRepository repository, IAuditService auditService)
|
|
{
|
|
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
|
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new instance from a template at a site.
|
|
/// </summary>
|
|
public async Task<Result<Instance>> CreateInstanceAsync(
|
|
string uniqueName,
|
|
int templateId,
|
|
int siteId,
|
|
int? areaId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(uniqueName))
|
|
return Result<Instance>.Failure("Instance unique name is required.");
|
|
|
|
// Verify template exists
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<Instance>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
// Check for duplicate unique name
|
|
var existing = await _repository.GetInstanceByUniqueNameAsync(uniqueName, cancellationToken);
|
|
if (existing != null)
|
|
return Result<Instance>.Failure($"Instance with unique name '{uniqueName}' already exists.");
|
|
|
|
// Verify area exists if specified
|
|
if (areaId.HasValue)
|
|
{
|
|
var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken);
|
|
if (area == null)
|
|
return Result<Instance>.Failure($"Area with ID {areaId.Value} not found.");
|
|
if (area.SiteId != siteId)
|
|
return Result<Instance>.Failure($"Area with ID {areaId.Value} does not belong to site {siteId}.");
|
|
}
|
|
|
|
var instance = new Instance(uniqueName)
|
|
{
|
|
TemplateId = templateId,
|
|
SiteId = siteId,
|
|
AreaId = areaId,
|
|
State = InstanceState.Disabled // New instances start disabled
|
|
};
|
|
|
|
await _repository.AddInstanceAsync(instance, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
await _auditService.LogAsync(user, "Create", "Instance", instance.Id.ToString(),
|
|
uniqueName, instance, cancellationToken);
|
|
|
|
return Result<Instance>.Success(instance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assigns an instance to an area.
|
|
/// </summary>
|
|
public async Task<Result<Instance>> AssignToAreaAsync(
|
|
int instanceId,
|
|
int? areaId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
|
if (instance == null)
|
|
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
|
|
|
if (areaId.HasValue)
|
|
{
|
|
var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken);
|
|
if (area == null)
|
|
return Result<Instance>.Failure($"Area with ID {areaId.Value} not found.");
|
|
if (area.SiteId != instance.SiteId)
|
|
return Result<Instance>.Failure($"Area with ID {areaId.Value} does not belong to the instance's site.");
|
|
}
|
|
|
|
instance.AreaId = areaId;
|
|
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
await _auditService.LogAsync(user, "AssignArea", "Instance", instance.Id.ToString(),
|
|
instance.UniqueName, instance, cancellationToken);
|
|
|
|
return Result<Instance>.Success(instance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets an attribute override for an instance. Only non-locked attributes can be overridden.
|
|
/// Cannot add or remove attributes — only override values of existing template attributes.
|
|
/// </summary>
|
|
public async Task<Result<InstanceAttributeOverride>> SetAttributeOverrideAsync(
|
|
int instanceId,
|
|
string attributeName,
|
|
string? overrideValue,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
|
if (instance == null)
|
|
return Result<InstanceAttributeOverride>.Failure($"Instance with ID {instanceId} not found.");
|
|
|
|
// Verify attribute exists in the template and is not locked
|
|
var templateAttrs = await _repository.GetAttributesByTemplateIdAsync(instance.TemplateId, cancellationToken);
|
|
var templateAttr = templateAttrs.FirstOrDefault(a => a.Name == attributeName);
|
|
if (templateAttr == null)
|
|
return Result<InstanceAttributeOverride>.Failure(
|
|
$"Attribute '{attributeName}' does not exist in template {instance.TemplateId}. Cannot add new attributes via overrides.");
|
|
|
|
if (templateAttr.IsLocked)
|
|
return Result<InstanceAttributeOverride>.Failure(
|
|
$"Attribute '{attributeName}' is locked and cannot be overridden.");
|
|
|
|
// Find existing override or create new one
|
|
var overrides = await _repository.GetOverridesByInstanceIdAsync(instanceId, cancellationToken);
|
|
var existingOverride = overrides.FirstOrDefault(o => o.AttributeName == attributeName);
|
|
|
|
if (existingOverride != null)
|
|
{
|
|
existingOverride.OverrideValue = overrideValue;
|
|
await _repository.UpdateInstanceAttributeOverrideAsync(existingOverride, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
await _auditService.LogAsync(user, "UpdateOverride", "InstanceAttributeOverride",
|
|
existingOverride.Id.ToString(), attributeName, existingOverride, cancellationToken);
|
|
|
|
return Result<InstanceAttributeOverride>.Success(existingOverride);
|
|
}
|
|
else
|
|
{
|
|
var newOverride = new InstanceAttributeOverride(attributeName)
|
|
{
|
|
InstanceId = instanceId,
|
|
OverrideValue = overrideValue
|
|
};
|
|
|
|
await _repository.AddInstanceAttributeOverrideAsync(newOverride, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
await _auditService.LogAsync(user, "CreateOverride", "InstanceAttributeOverride",
|
|
newOverride.Id.ToString(), attributeName, newOverride, cancellationToken);
|
|
|
|
return Result<InstanceAttributeOverride>.Success(newOverride);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets a per-instance alarm override. The alarm must exist in the
|
|
/// instance's effective alarm set (direct, inherited, or composed) and
|
|
/// must not be locked. For HiLo alarms, the override JSON merges into the
|
|
/// inherited TriggerConfiguration setpoint-by-setpoint; for binary trigger
|
|
/// types, it replaces the whole config.
|
|
/// </summary>
|
|
public async Task<Result<InstanceAlarmOverride>> SetAlarmOverrideAsync(
|
|
int instanceId,
|
|
string alarmCanonicalName,
|
|
string? triggerConfigurationOverride,
|
|
int? priorityLevelOverride,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
|
if (instance == null)
|
|
return Result<InstanceAlarmOverride>.Failure($"Instance with ID {instanceId} not found.");
|
|
|
|
// Verify the alarm exists in the instance's effective alarm set and is
|
|
// not locked. The effective set is resolved via TemplateResolver so that
|
|
// composed (path-qualified) and inherited alarms are found — a lookup
|
|
// against the template's direct alarms alone would miss them, silently
|
|
// accepting an override for a non-existent name or bypassing the lock
|
|
// rule for a composed alarm. Mirrors SetAttributeOverrideAsync.
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var resolvedAlarm = TemplateResolver
|
|
.ResolveAllMembers(instance.TemplateId, allTemplates)
|
|
.FirstOrDefault(m => m.MemberType == "Alarm" && m.CanonicalName == alarmCanonicalName);
|
|
|
|
if (resolvedAlarm == null)
|
|
return Result<InstanceAlarmOverride>.Failure(
|
|
$"Alarm '{alarmCanonicalName}' does not exist in template {instance.TemplateId}. " +
|
|
"Cannot override an unknown alarm.");
|
|
|
|
if (resolvedAlarm.IsLocked)
|
|
return Result<InstanceAlarmOverride>.Failure(
|
|
$"Alarm '{alarmCanonicalName}' is locked and cannot be overridden.");
|
|
|
|
var existingOverride = await _repository.GetAlarmOverrideAsync(
|
|
instanceId, alarmCanonicalName, cancellationToken);
|
|
|
|
if (existingOverride != null)
|
|
{
|
|
existingOverride.TriggerConfigurationOverride = triggerConfigurationOverride;
|
|
existingOverride.PriorityLevelOverride = priorityLevelOverride;
|
|
await _repository.UpdateInstanceAlarmOverrideAsync(existingOverride, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
await _auditService.LogAsync(user, "UpdateAlarmOverride", "InstanceAlarmOverride",
|
|
existingOverride.Id.ToString(), alarmCanonicalName, existingOverride, cancellationToken);
|
|
|
|
return Result<InstanceAlarmOverride>.Success(existingOverride);
|
|
}
|
|
else
|
|
{
|
|
var newOverride = new InstanceAlarmOverride(alarmCanonicalName)
|
|
{
|
|
InstanceId = instanceId,
|
|
TriggerConfigurationOverride = triggerConfigurationOverride,
|
|
PriorityLevelOverride = priorityLevelOverride
|
|
};
|
|
|
|
await _repository.AddInstanceAlarmOverrideAsync(newOverride, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
await _auditService.LogAsync(user, "CreateAlarmOverride", "InstanceAlarmOverride",
|
|
newOverride.Id.ToString(), alarmCanonicalName, newOverride, cancellationToken);
|
|
|
|
return Result<InstanceAlarmOverride>.Success(newOverride);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a per-instance alarm override. After removal the instance
|
|
/// inherits the template alarm config unchanged.
|
|
/// </summary>
|
|
public async Task<Result<bool>> DeleteAlarmOverrideAsync(
|
|
int instanceId,
|
|
string alarmCanonicalName,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var existing = await _repository.GetAlarmOverrideAsync(
|
|
instanceId, alarmCanonicalName, cancellationToken);
|
|
if (existing == null)
|
|
return Result<bool>.Failure($"No alarm override for '{alarmCanonicalName}' on instance {instanceId}.");
|
|
|
|
await _repository.DeleteInstanceAlarmOverrideAsync(existing.Id, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
await _auditService.LogAsync(user, "DeleteAlarmOverride", "InstanceAlarmOverride",
|
|
existing.Id.ToString(), alarmCanonicalName, existing, cancellationToken);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets connection bindings for an instance in bulk.
|
|
/// </summary>
|
|
public async Task<Result<IReadOnlyList<InstanceConnectionBinding>>> SetConnectionBindingsAsync(
|
|
int instanceId,
|
|
IReadOnlyList<ConnectionBinding> bindings,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
|
if (instance == null)
|
|
return Result<IReadOnlyList<InstanceConnectionBinding>>.Failure($"Instance with ID {instanceId} not found.");
|
|
|
|
var existingBindings = await _repository.GetBindingsByInstanceIdAsync(instanceId, cancellationToken);
|
|
var existingMap = existingBindings.ToDictionary(b => b.AttributeName, StringComparer.Ordinal);
|
|
|
|
var results = new List<InstanceConnectionBinding>();
|
|
|
|
foreach (var (attrName, connId) in bindings)
|
|
{
|
|
if (existingMap.TryGetValue(attrName, out var existing))
|
|
{
|
|
existing.DataConnectionId = connId;
|
|
await _repository.UpdateInstanceConnectionBindingAsync(existing, cancellationToken);
|
|
results.Add(existing);
|
|
}
|
|
else
|
|
{
|
|
var binding = new InstanceConnectionBinding(attrName)
|
|
{
|
|
InstanceId = instanceId,
|
|
DataConnectionId = connId
|
|
};
|
|
await _repository.AddInstanceConnectionBindingAsync(binding, cancellationToken);
|
|
results.Add(binding);
|
|
}
|
|
}
|
|
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
await _auditService.LogAsync(user, "SetConnectionBindings", "Instance",
|
|
instance.Id.ToString(), instance.UniqueName, bindings, cancellationToken);
|
|
|
|
return Result<IReadOnlyList<InstanceConnectionBinding>>.Success(results);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enables an instance.
|
|
/// </summary>
|
|
public async Task<Result<Instance>> EnableAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
|
{
|
|
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
|
if (instance == null)
|
|
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
|
|
|
instance.State = InstanceState.Enabled;
|
|
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
await _auditService.LogAsync(user, "Enable", "Instance", instance.Id.ToString(),
|
|
instance.UniqueName, instance, cancellationToken);
|
|
|
|
return Result<Instance>.Success(instance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disables an instance.
|
|
/// </summary>
|
|
public async Task<Result<Instance>> DisableAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
|
{
|
|
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
|
if (instance == null)
|
|
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
|
|
|
instance.State = InstanceState.Disabled;
|
|
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
await _auditService.LogAsync(user, "Disable", "Instance", instance.Id.ToString(),
|
|
instance.UniqueName, instance, cancellationToken);
|
|
|
|
return Result<Instance>.Success(instance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes an instance.
|
|
/// </summary>
|
|
public async Task<Result<bool>> DeleteAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
|
{
|
|
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
|
if (instance == null)
|
|
return Result<bool>.Failure($"Instance with ID {instanceId} not found.");
|
|
|
|
await _repository.DeleteInstanceAsync(instanceId, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
await _auditService.LogAsync(user, "Delete", "Instance", instanceId.ToString(),
|
|
instance.UniqueName, null, cancellationToken);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets an instance by ID.
|
|
/// </summary>
|
|
public async Task<Instance?> GetByIdAsync(int instanceId, CancellationToken cancellationToken = default) =>
|
|
await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Gets all instances for a site.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<Instance>> GetBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
|
await _repository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
|
|
}
|