diff --git a/src/ScadaLink.CLI/Commands/DataConnectionCommands.cs b/src/ScadaLink.CLI/Commands/DataConnectionCommands.cs index 2ea93fb..723bf00 100644 --- a/src/ScadaLink.CLI/Commands/DataConnectionCommands.cs +++ b/src/ScadaLink.CLI/Commands/DataConnectionCommands.cs @@ -15,8 +15,6 @@ public static class DataConnectionCommands command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption)); - command.Add(BuildAssign(urlOption, formatOption, usernameOption, passwordOption)); - command.Add(BuildUnassign(urlOption, formatOption, usernameOption, passwordOption)); return command; } @@ -60,49 +58,41 @@ public static class DataConnectionCommands return cmd; } - private static Command BuildUnassign(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) - { - var idOption = new Option("--assignment-id") { Description = "Assignment ID", Required = true }; - var cmd = new Command("unassign") { Description = "Unassign a data connection from a site" }; - cmd.Add(idOption); - cmd.SetAction(async (ParseResult result) => - { - var id = result.GetValue(idOption); - return await CommandHelpers.ExecuteCommandAsync( - result, urlOption, formatOption, usernameOption, passwordOption, new UnassignDataConnectionFromSiteCommand(id)); - }); - return cmd; - } - private static Command BuildList(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { - var cmd = new Command("list") { Description = "List all data connections" }; + var siteIdOption = new Option("--site-id") { Description = "Filter by site ID" }; + var cmd = new Command("list") { Description = "List data connections" }; + cmd.Add(siteIdOption); cmd.SetAction(async (ParseResult result) => { + var siteId = result.GetValue(siteIdOption); return await CommandHelpers.ExecuteCommandAsync( - result, urlOption, formatOption, usernameOption, passwordOption, new ListDataConnectionsCommand()); + result, urlOption, formatOption, usernameOption, passwordOption, new ListDataConnectionsCommand(siteId)); }); return cmd; } private static Command BuildCreate(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { + var siteIdOption = new Option("--site-id") { Description = "Site ID", Required = true }; var nameOption = new Option("--name") { Description = "Connection name", Required = true }; var protocolOption = new Option("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true }; var configOption = new Option("--configuration") { Description = "Connection configuration JSON" }; var cmd = new Command("create") { Description = "Create a new data connection" }; + cmd.Add(siteIdOption); cmd.Add(nameOption); cmd.Add(protocolOption); cmd.Add(configOption); cmd.SetAction(async (ParseResult result) => { + var siteId = result.GetValue(siteIdOption); var name = result.GetValue(nameOption)!; var protocol = result.GetValue(protocolOption)!; var config = result.GetValue(configOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, - new CreateDataConnectionCommand(name, protocol, config)); + new CreateDataConnectionCommand(siteId, name, protocol, config)); }); return cmd; } @@ -120,23 +110,4 @@ public static class DataConnectionCommands }); return cmd; } - - private static Command BuildAssign(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) - { - var connectionIdOption = new Option("--connection-id") { Description = "Data connection ID", Required = true }; - var siteIdOption = new Option("--site-id") { Description = "Site ID", Required = true }; - - var cmd = new Command("assign") { Description = "Assign a data connection to a site" }; - cmd.Add(connectionIdOption); - cmd.Add(siteIdOption); - cmd.SetAction(async (ParseResult result) => - { - var connectionId = result.GetValue(connectionIdOption); - var siteId = result.GetValue(siteIdOption); - return await CommandHelpers.ExecuteCommandAsync( - result, urlOption, formatOption, usernameOption, passwordOption, - new AssignDataConnectionToSiteCommand(connectionId, siteId)); - }); - return cmd; - } } diff --git a/src/ScadaLink.CLI/README.md b/src/ScadaLink.CLI/README.md index 1c42fe9..7f31fa8 100644 --- a/src/ScadaLink.CLI/README.md +++ b/src/ScadaLink.CLI/README.md @@ -603,22 +603,27 @@ scadalink --url data-connection get --id #### `data-connection list` -List all configured data connections. +List data connections, optionally filtered by site. ```sh -scadalink --url data-connection list -``` - -#### `data-connection create` - -Create a new data connection definition. - -```sh -scadalink --url data-connection create --name --protocol [--configuration ] +scadalink --url data-connection list [--site-id ] ``` | Option | Required | Description | |--------|----------|-------------| +| `--site-id` | no | Filter by site ID | + +#### `data-connection create` + +Create a new data connection belonging to a specific site. + +```sh +scadalink --url data-connection create --site-id --name --protocol [--configuration ] +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--site-id` | yes | Site ID the connection belongs to | | `--name` | yes | Connection name | | `--protocol` | yes | Protocol identifier (e.g. `OpcUa`) | | `--configuration` | no | Protocol-specific configuration as a JSON string | @@ -650,32 +655,6 @@ scadalink --url data-connection delete --id |--------|----------|-------------| | `--id` | yes | Data connection ID | -#### `data-connection assign` - -Assign a data connection to a site. - -```sh -scadalink --url data-connection assign --connection-id --site-id -``` - -| Option | Required | Description | -|--------|----------|-------------| -| `--connection-id` | yes | Data connection ID | -| `--site-id` | yes | Site ID | - -#### `data-connection unassign` - -Remove a data connection assignment from a site. - -```sh -scadalink --url data-connection unassign --connection-id --site-id -``` - -| Option | Required | Description | -|--------|----------|-------------| -| `--connection-id` | yes | Data connection ID | -| `--site-id` | yes | Site ID | - --- ### `external-system` — Manage external HTTP systems diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor index 0231fa0..e5b1fb3 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor @@ -21,6 +21,26 @@ {
+ @if (Id.HasValue) + { +
+ + +
+ } + else + { +
+ + +
+ }
@@ -57,6 +77,9 @@ private bool _loading = true; private DataConnection? _editingConnection; + private List _sites = new(); + private int _formSiteId; + private string _siteName = string.Empty; private string _formName = string.Empty; private string _formProtocol = string.Empty; private string? _formConfiguration; @@ -64,6 +87,8 @@ protected override async Task OnInitializedAsync() { + _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); + if (Id.HasValue) { try @@ -71,6 +96,8 @@ _editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value); if (_editingConnection != null) { + _formSiteId = _editingConnection.SiteId; + _siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}"; _formName = _editingConnection.Name; _formProtocol = _editingConnection.Protocol; _formConfiguration = _editingConnection.Configuration; @@ -87,6 +114,7 @@ private async Task SaveConnection() { _formError = null; + if (_formSiteId == 0) { _formError = "Site is required."; return; } if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; } if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; } @@ -101,7 +129,7 @@ } else { - var conn = new DataConnection(_formName.Trim(), _formProtocol) + var conn = new DataConnection(_formName.Trim(), _formProtocol, _formSiteId) { Configuration = _formConfiguration?.Trim() }; diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor index d5ba31a..90353f3 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor @@ -25,58 +25,14 @@ } else { - @* Assignment form *@ - @if (_showAssignForm) - { -
-
-
Assign Connection to Site
-
-
- - -
-
- - -
-
- - -
-
- @if (_assignError != null) - { -
@_assignError
- } -
-
- } - -
- -
- + - @@ -93,29 +49,8 @@ + -
ID Name ProtocolSite ConfigurationAssigned Sites Actions
@conn.Id @conn.Name @conn.Protocol@(_siteLookup.GetValueOrDefault(conn.SiteId)?.Name ?? $"Site {conn.SiteId}") @(conn.Configuration ?? "—") - @{ - var assignedSites = _connectionSites.GetValueOrDefault(conn.Id); - } - @if (assignedSites != null && assignedSites.Count > 0) - { - @foreach (var assignment in assignedSites) - { - var siteName = _sites.FirstOrDefault(s => s.Id == assignment.SiteId)?.Name ?? $"Site {assignment.SiteId}"; - - @siteName - - - } - } - else - { - None - } - @@ -131,16 +66,10 @@ @code { private List _connections = new(); - private List _sites = new(); - private Dictionary> _connectionSites = new(); + private Dictionary _siteLookup = new(); private bool _loading = true; private string? _errorMessage; - private bool _showAssignForm; - private int _assignConnectionId; - private int _assignSiteId; - private string? _assignError; - private ToastNotification _toast = default!; private ConfirmDialog _confirmDialog = default!; @@ -155,24 +84,9 @@ _errorMessage = null; try { - _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); + var sites = await SiteRepository.GetAllSitesAsync(); + _siteLookup = sites.ToDictionary(s => s.Id); _connections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList(); - - // Load site assignments for each connection - _connectionSites.Clear(); - foreach (var site in _sites) - { - var siteConns = await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id); - foreach (var conn in siteConns) - { - if (!_connectionSites.ContainsKey(conn.Id)) - _connectionSites[conn.Id] = new List(); - - var assignment = await SiteRepository.GetSiteDataConnectionAssignmentAsync(site.Id, conn.Id); - if (assignment != null) - _connectionSites[conn.Id].Add(assignment); - } - } } catch (Exception ex) { @@ -199,58 +113,4 @@ _toast.ShowError($"Delete failed: {ex.Message}"); } } - - private void ShowAssignForm() - { - _assignConnectionId = 0; - _assignSiteId = 0; - _assignError = null; - _showAssignForm = true; - } - - private void CancelAssignForm() - { - _showAssignForm = false; - _assignError = null; - } - - private async Task SaveAssignment() - { - _assignError = null; - if (_assignConnectionId == 0) { _assignError = "Select a connection."; return; } - if (_assignSiteId == 0) { _assignError = "Select a site."; return; } - - try - { - var assignment = new SiteDataConnectionAssignment - { - SiteId = _assignSiteId, - DataConnectionId = _assignConnectionId - }; - await SiteRepository.AddSiteDataConnectionAssignmentAsync(assignment); - await SiteRepository.SaveChangesAsync(); - _showAssignForm = false; - _toast.ShowSuccess("Connection assigned to site."); - await LoadDataAsync(); - } - catch (Exception ex) - { - _assignError = $"Assignment failed: {ex.Message}"; - } - } - - private async Task RemoveAssignment(SiteDataConnectionAssignment assignment) - { - try - { - await SiteRepository.DeleteSiteDataConnectionAssignmentAsync(assignment.Id); - await SiteRepository.SaveChangesAsync(); - _toast.ShowSuccess("Assignment removed."); - await LoadDataAsync(); - } - catch (Exception ex) - { - _toast.ShowError($"Remove failed: {ex.Message}"); - } - } } diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor index 9a1b942..be7ac4d 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor @@ -178,10 +178,9 @@ _deploying = true; try { - var command = await ArtifactDeploymentService.BuildDeployArtifactsCommandAsync(); var user = await GetCurrentUserAsync(); var result = await ArtifactDeploymentService.RetryForSiteAsync( - site.SiteIdentifier, command, user); + site.Id, site.SiteIdentifier, user); if (result.IsSuccess) _toast.ShowSuccess($"Artifacts deployed to '{site.Name}'."); @@ -203,9 +202,8 @@ _deploying = true; try { - var command = await ArtifactDeploymentService.BuildDeployArtifactsCommandAsync(); var user = await GetCurrentUserAsync(); - var result = await ArtifactDeploymentService.DeployToAllSitesAsync(command, user); + var result = await ArtifactDeploymentService.DeployToAllSitesAsync(user); if (result.IsSuccess) { diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor index db74583..cd9255a 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor @@ -697,13 +697,8 @@ var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId); _bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).ToList(); - // Load data connections for this site + // Load data connections for this site (each connection now belongs to exactly one site) _siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(inst.SiteId)).ToList(); - if (_siteConnections.Count == 0) - { - // Also show unassigned connections (they may not be assigned to a site yet) - _siteConnections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList(); - } // Load existing bindings var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(inst.Id); diff --git a/src/ScadaLink.Commons/Entities/Sites/DataConnection.cs b/src/ScadaLink.Commons/Entities/Sites/DataConnection.cs index 397cf38..16df869 100644 --- a/src/ScadaLink.Commons/Entities/Sites/DataConnection.cs +++ b/src/ScadaLink.Commons/Entities/Sites/DataConnection.cs @@ -3,13 +3,15 @@ namespace ScadaLink.Commons.Entities.Sites; public class DataConnection { public int Id { get; set; } + public int SiteId { get; set; } public string Name { get; set; } public string Protocol { get; set; } public string? Configuration { get; set; } - public DataConnection(string name, string protocol) + public DataConnection(string name, string protocol, int siteId) { Name = name ?? throw new ArgumentNullException(nameof(name)); Protocol = protocol ?? throw new ArgumentNullException(nameof(protocol)); + SiteId = siteId; } } diff --git a/src/ScadaLink.Commons/Entities/Sites/SiteDataConnectionAssignment.cs b/src/ScadaLink.Commons/Entities/Sites/SiteDataConnectionAssignment.cs deleted file mode 100644 index 2caca44..0000000 --- a/src/ScadaLink.Commons/Entities/Sites/SiteDataConnectionAssignment.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ScadaLink.Commons.Entities.Sites; - -public class SiteDataConnectionAssignment -{ - public int Id { get; set; } - public int SiteId { get; set; } - public int DataConnectionId { get; set; } -} diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/ICentralUiRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/ICentralUiRepository.cs index a1dbb50..ff012ad 100644 --- a/src/ScadaLink.Commons/Interfaces/Repositories/ICentralUiRepository.cs +++ b/src/ScadaLink.Commons/Interfaces/Repositories/ICentralUiRepository.cs @@ -10,7 +10,7 @@ public interface ICentralUiRepository { Task> GetAllSitesAsync(CancellationToken cancellationToken = default); Task> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default); - Task> GetAllSiteDataConnectionAssignmentsAsync(CancellationToken cancellationToken = default); + Task> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default); Task> GetTemplateTreeAsync(CancellationToken cancellationToken = default); Task> GetInstancesFilteredAsync(int? siteId = null, int? templateId = null, string? searchTerm = null, CancellationToken cancellationToken = default); Task> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default); diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/ISiteRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/ISiteRepository.cs index a1b8beb..81be37c 100644 --- a/src/ScadaLink.Commons/Interfaces/Repositories/ISiteRepository.cs +++ b/src/ScadaLink.Commons/Interfaces/Repositories/ISiteRepository.cs @@ -24,11 +24,6 @@ public interface ISiteRepository Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default); Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default); - // Site-Connection Assignments - Task GetSiteDataConnectionAssignmentAsync(int siteId, int dataConnectionId, CancellationToken cancellationToken = default); - Task AddSiteDataConnectionAssignmentAsync(SiteDataConnectionAssignment assignment, CancellationToken cancellationToken = default); - Task DeleteSiteDataConnectionAssignmentAsync(int id, CancellationToken cancellationToken = default); - // Instances (for deletion constraint checks) Task> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default); diff --git a/src/ScadaLink.Commons/Messages/Management/DataConnectionCommands.cs b/src/ScadaLink.Commons/Messages/Management/DataConnectionCommands.cs index 142a610..1e33aab 100644 --- a/src/ScadaLink.Commons/Messages/Management/DataConnectionCommands.cs +++ b/src/ScadaLink.Commons/Messages/Management/DataConnectionCommands.cs @@ -1,9 +1,7 @@ namespace ScadaLink.Commons.Messages.Management; -public record ListDataConnectionsCommand; +public record ListDataConnectionsCommand(int? SiteId = null); public record GetDataConnectionCommand(int DataConnectionId); -public record CreateDataConnectionCommand(string Name, string Protocol, string? Configuration); +public record CreateDataConnectionCommand(int SiteId, string Name, string Protocol, string? Configuration); public record UpdateDataConnectionCommand(int DataConnectionId, string Name, string Protocol, string? Configuration); public record DeleteDataConnectionCommand(int DataConnectionId); -public record AssignDataConnectionToSiteCommand(int DataConnectionId, int SiteId); -public record UnassignDataConnectionFromSiteCommand(int AssignmentId); diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs index 35eaf3c..66e4da0 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs @@ -46,26 +46,11 @@ public class DataConnectionConfiguration : IEntityTypeConfiguration d.Configuration) .HasMaxLength(4000); - builder.HasIndex(d => d.Name).IsUnique(); - } -} - -public class SiteDataConnectionAssignmentConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(a => a.Id); - builder.HasOne() .WithMany() - .HasForeignKey(a => a.SiteId) - .OnDelete(DeleteBehavior.Cascade); + .HasForeignKey(d => d.SiteId) + .OnDelete(DeleteBehavior.Restrict); - builder.HasOne() - .WithMany() - .HasForeignKey(a => a.DataConnectionId) - .OnDelete(DeleteBehavior.Cascade); - - builder.HasIndex(a => new { a.SiteId, a.DataConnectionId }).IsUnique(); + builder.HasIndex(d => new { d.SiteId, d.Name }).IsUnique(); } } diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260321210402_AddSiteIdToDataConnections.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260321210402_AddSiteIdToDataConnections.Designer.cs new file mode 100644 index 0000000..d3e42be --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260321210402_AddSiteIdToDataConnections.Designer.cs @@ -0,0 +1,1227 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260321210402_AddSiteIdToDataConnections")] + partial class AddSiteIdToDataConnections + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyValue") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Configuration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("GrpcNodeBAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260321210402_AddSiteIdToDataConnections.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260321210402_AddSiteIdToDataConnections.cs new file mode 100644 index 0000000..8582074 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260321210402_AddSiteIdToDataConnections.cs @@ -0,0 +1,184 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + public partial class AddSiteIdToDataConnections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Step 1: Drop old unique index on Name (allows duplicate names across sites) + migrationBuilder.DropIndex( + name: "IX_DataConnections_Name", + table: "DataConnections"); + + // Step 2: Add nullable SiteId column + migrationBuilder.AddColumn( + name: "SiteId", + table: "DataConnections", + type: "int", + nullable: true); + + // Step 3: Migrate data from SiteDataConnectionAssignments + migrationBuilder.Sql(@" + -- Phase A: Assign the first site to each existing DataConnection + UPDATE dc + SET dc.SiteId = a.SiteId + FROM DataConnections dc + INNER JOIN ( + SELECT DataConnectionId, MIN(SiteId) AS SiteId + FROM SiteDataConnectionAssignments + GROUP BY DataConnectionId + ) a ON dc.Id = a.DataConnectionId + WHERE dc.SiteId IS NULL; + + -- Phase B: For connections assigned to additional sites, create copies + -- and update InstanceConnectionBindings to point to the new copy + DECLARE @AssignSiteId INT, @AssignConnId INT, @NewConnId INT; + DECLARE @OrigName NVARCHAR(200), @OrigProtocol NVARCHAR(50), @OrigConfig NVARCHAR(4000); + + DECLARE assignment_cursor CURSOR FOR + SELECT a.SiteId, a.DataConnectionId + FROM SiteDataConnectionAssignments a + INNER JOIN DataConnections dc ON a.DataConnectionId = dc.Id + WHERE dc.SiteId <> a.SiteId; + + OPEN assignment_cursor; + FETCH NEXT FROM assignment_cursor INTO @AssignSiteId, @AssignConnId; + + WHILE @@FETCH_STATUS = 0 + BEGIN + SELECT @OrigName = Name, @OrigProtocol = Protocol, @OrigConfig = Configuration + FROM DataConnections WHERE Id = @AssignConnId; + + INSERT INTO DataConnections (SiteId, Name, Protocol, Configuration) + VALUES (@AssignSiteId, @OrigName, @OrigProtocol, @OrigConfig); + + SET @NewConnId = SCOPE_IDENTITY(); + + -- Update bindings for instances on this site to point to the new connection + UPDATE icb + SET icb.DataConnectionId = @NewConnId + FROM InstanceConnectionBindings icb + INNER JOIN Instances i ON icb.InstanceId = i.Id + WHERE icb.DataConnectionId = @AssignConnId + AND i.SiteId = @AssignSiteId; + + FETCH NEXT FROM assignment_cursor INTO @AssignSiteId, @AssignConnId; + END + + CLOSE assignment_cursor; + DEALLOCATE assignment_cursor; + + -- Phase C: Handle any DataConnections not assigned to any site + -- (assign to the first site as a fallback) + UPDATE dc + SET dc.SiteId = (SELECT TOP 1 Id FROM Sites ORDER BY Id) + FROM DataConnections dc + WHERE dc.SiteId IS NULL; + "); + + // Step 4: Make SiteId non-nullable + migrationBuilder.AlterColumn( + name: "SiteId", + table: "DataConnections", + type: "int", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "int", + oldNullable: true); + + // Step 5: Add composite unique index and FK + migrationBuilder.CreateIndex( + name: "IX_DataConnections_SiteId_Name", + table: "DataConnections", + columns: new[] { "SiteId", "Name" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_DataConnections_Sites_SiteId", + table: "DataConnections", + column: "SiteId", + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + // Step 6: Drop SiteDataConnectionAssignments table + migrationBuilder.DropTable( + name: "SiteDataConnectionAssignments"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Recreate SiteDataConnectionAssignments table + migrationBuilder.CreateTable( + name: "SiteDataConnectionAssignments", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + DataConnectionId = table.Column(type: "int", nullable: false), + SiteId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SiteDataConnectionAssignments", x => x.Id); + table.ForeignKey( + name: "FK_SiteDataConnectionAssignments_DataConnections_DataConnectionId", + column: x => x.DataConnectionId, + principalTable: "DataConnections", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SiteDataConnectionAssignments_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SiteDataConnectionAssignments_DataConnectionId", + table: "SiteDataConnectionAssignments", + column: "DataConnectionId"); + + migrationBuilder.CreateIndex( + name: "IX_SiteDataConnectionAssignments_SiteId_DataConnectionId", + table: "SiteDataConnectionAssignments", + columns: new[] { "SiteId", "DataConnectionId" }, + unique: true); + + // Migrate data back + migrationBuilder.Sql(@" + INSERT INTO SiteDataConnectionAssignments (SiteId, DataConnectionId) + SELECT SiteId, Id FROM DataConnections; + "); + + // Remove FK and composite index + migrationBuilder.DropForeignKey( + name: "FK_DataConnections_Sites_SiteId", + table: "DataConnections"); + + migrationBuilder.DropIndex( + name: "IX_DataConnections_SiteId_Name", + table: "DataConnections"); + + // Restore unique index on Name + migrationBuilder.CreateIndex( + name: "IX_DataConnections_Name", + table: "DataConnections", + column: "Name", + unique: true); + + // Drop SiteId column + migrationBuilder.DropColumn( + name: "SiteId", + table: "DataConnections"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index 3cf7bee..8cee84c 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -766,9 +766,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .HasMaxLength(50) .HasColumnType("nvarchar(50)"); + b.Property("SiteId") + .HasColumnType("int"); + b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("SiteId", "Name") .IsUnique(); b.ToTable("DataConnections"); @@ -821,30 +824,6 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.ToTable("Sites"); }); - modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.SiteDataConnectionAssignment", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("DataConnectionId") - .HasColumnType("int"); - - b.Property("SiteId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("DataConnectionId"); - - b.HasIndex("SiteId", "DataConnectionId") - .IsUnique(); - - b.ToTable("SiteDataConnectionAssignments"); - }); - modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => { b.Property("Id") @@ -1153,18 +1132,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .IsRequired(); }); - modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.SiteDataConnectionAssignment", b => + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => { - b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) - .WithMany() - .HasForeignKey("DataConnectionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) .WithMany() .HasForeignKey("SiteId") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .IsRequired(); }); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs index 2366de3..0a8acb7 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs @@ -27,17 +27,18 @@ public class CentralUiRepository : ICentralUiRepository public async Task> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) { - return await _context.SiteDataConnectionAssignments + return await _context.DataConnections .AsNoTracking() - .Where(a => a.SiteId == siteId) - .Join(_context.DataConnections, a => a.DataConnectionId, d => d.Id, (_, d) => d) + .Where(d => d.SiteId == siteId) + .OrderBy(d => d.Name) .ToListAsync(cancellationToken); } - public async Task> GetAllSiteDataConnectionAssignmentsAsync(CancellationToken cancellationToken = default) + public async Task> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default) { - return await _context.SiteDataConnectionAssignments + return await _context.DataConnections .AsNoTracking() + .OrderBy(d => d.Name) .ToListAsync(cancellationToken); } diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteRepository.cs index 6a61474..7ffb5cc 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteRepository.cs @@ -76,13 +76,8 @@ public class SiteRepository : ISiteRepository public async Task> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) { - var connectionIds = await _dbContext.SiteDataConnectionAssignments - .Where(a => a.SiteId == siteId) - .Select(a => a.DataConnectionId) - .ToListAsync(cancellationToken); - return await _dbContext.DataConnections - .Where(c => connectionIds.Contains(c.Id)) + .Where(c => c.SiteId == siteId) .OrderBy(c => c.Name) .ToListAsync(cancellationToken); } @@ -107,43 +102,13 @@ public class SiteRepository : ISiteRepository } else { - var stub = new DataConnection("stub", "stub") { Id = id }; + var stub = new DataConnection("stub", "stub", 0) { Id = id }; _dbContext.DataConnections.Attach(stub); _dbContext.DataConnections.Remove(stub); } return Task.CompletedTask; } - // --- Site-Connection Assignments --- - - public async Task GetSiteDataConnectionAssignmentAsync( - int siteId, int dataConnectionId, CancellationToken cancellationToken = default) - { - return await _dbContext.SiteDataConnectionAssignments - .FirstOrDefaultAsync(a => a.SiteId == siteId && a.DataConnectionId == dataConnectionId, cancellationToken); - } - - public async Task AddSiteDataConnectionAssignmentAsync(SiteDataConnectionAssignment assignment, CancellationToken cancellationToken = default) - { - await _dbContext.SiteDataConnectionAssignments.AddAsync(assignment, cancellationToken); - } - - public Task DeleteSiteDataConnectionAssignmentAsync(int id, CancellationToken cancellationToken = default) - { - var entity = _dbContext.SiteDataConnectionAssignments.Local.FirstOrDefault(a => a.Id == id); - if (entity != null) - { - _dbContext.SiteDataConnectionAssignments.Remove(entity); - } - else - { - var stub = new SiteDataConnectionAssignment { Id = id }; - _dbContext.SiteDataConnectionAssignments.Attach(stub); - _dbContext.SiteDataConnectionAssignments.Remove(stub); - } - return Task.CompletedTask; - } - // --- Instances (for deletion constraint checks) --- public async Task> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) diff --git a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs index fddb490..a16bcb1 100644 --- a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs +++ b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs @@ -35,7 +35,6 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext // Sites public DbSet Sites => Set(); public DbSet DataConnections => Set(); - public DbSet SiteDataConnectionAssignments => Set(); // Deployment public DbSet DeploymentRecords => Set(); diff --git a/src/ScadaLink.DeploymentManager/ArtifactDeploymentService.cs b/src/ScadaLink.DeploymentManager/ArtifactDeploymentService.cs index d216afe..a653a62 100644 --- a/src/ScadaLink.DeploymentManager/ArtifactDeploymentService.cs +++ b/src/ScadaLink.DeploymentManager/ArtifactDeploymentService.cs @@ -55,16 +55,18 @@ public class ArtifactDeploymentService } /// - /// Collects all artifact types from repositories and builds a . + /// Collects all artifact types from repositories and builds a + /// scoped to a specific site's data connections. /// public async Task BuildDeployArtifactsCommandAsync( + int siteId, CancellationToken cancellationToken = default) { var sharedScripts = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken); var externalSystems = await _externalSystemRepo.GetAllExternalSystemsAsync(cancellationToken); var dbConnections = await _externalSystemRepo.GetAllDatabaseConnectionsAsync(cancellationToken); var notificationLists = await _notificationRepo.GetAllNotificationListsAsync(cancellationToken); - var dataConnections = await _siteRepo.GetAllDataConnectionsAsync(cancellationToken); + var dataConnections = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, cancellationToken); var smtpConfigurations = await _notificationRepo.GetAllSmtpConfigurationsAsync(cancellationToken); // Map shared scripts @@ -120,10 +122,10 @@ public class ArtifactDeploymentService } /// - /// Deploys artifacts to all sites. Returns per-site result matrix. + /// Deploys artifacts to all sites. Builds a per-site command with that site's data connections. + /// Returns per-site result matrix. /// public async Task> DeployToAllSitesAsync( - DeployArtifactsCommand command, string user, CancellationToken cancellationToken = default) { @@ -131,9 +133,17 @@ public class ArtifactDeploymentService if (sites.Count == 0) return Result.Failure("No sites configured."); + var deploymentId = Guid.NewGuid().ToString("N"); var perSiteResults = new Dictionary(); - // Deploy to each site with per-site timeout + // Build per-site commands sequentially (DbContext is not thread-safe) + var siteCommands = new Dictionary(); + foreach (var site in sites) + { + siteCommands[site.Id] = await BuildDeployArtifactsCommandAsync(site.Id, cancellationToken); + } + + // Deploy to each site in parallel with per-site timeout var tasks = sites.Select(async site => { try @@ -141,6 +151,8 @@ public class ArtifactDeploymentService using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(_options.ArtifactDeploymentTimeoutPerSite); + var command = siteCommands[site.Id]; + _logger.LogInformation( "Deploying artifacts to site {SiteId} ({SiteName}), deploymentId={DeploymentId}", site.SiteIdentifier, site.Name, command.DeploymentId); @@ -188,13 +200,13 @@ public class ArtifactDeploymentService await _deploymentRepo.SaveChangesAsync(cancellationToken); var summary = new ArtifactDeploymentSummary( - command.DeploymentId, + deploymentId, results.ToList(), results.Count(r => r.Success), results.Count(r => !r.Success)); await _auditService.LogAsync(user, "DeployArtifacts", "SystemArtifact", - command.DeploymentId, "Artifacts", + deploymentId, "Artifacts", new { summary.SuccessCount, summary.FailureCount }, cancellationToken); @@ -205,8 +217,8 @@ public class ArtifactDeploymentService /// WP-7: Retry artifact deployment to a specific site that previously failed. /// public async Task> RetryForSiteAsync( - string siteId, - DeployArtifactsCommand command, + int siteDbId, + string siteIdentifier, string user, CancellationToken cancellationToken = default) { @@ -215,12 +227,13 @@ public class ArtifactDeploymentService using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(_options.ArtifactDeploymentTimeoutPerSite); - var response = await _communicationService.DeployArtifactsAsync(siteId, command, cts.Token); + var command = await BuildDeployArtifactsCommandAsync(siteDbId, cts.Token); + var response = await _communicationService.DeployArtifactsAsync(siteIdentifier, command, cts.Token); - var result = new SiteArtifactResult(siteId, siteId, response.Success, response.ErrorMessage); + var result = new SiteArtifactResult(siteIdentifier, siteIdentifier, response.Success, response.ErrorMessage); await _auditService.LogAsync(user, "RetryArtifactDeployment", "SystemArtifact", - command.DeploymentId, siteId, new { response.Success }, cancellationToken); + command.DeploymentId, siteIdentifier, new { response.Success }, cancellationToken); return response.Success ? Result.Success(result) @@ -228,7 +241,7 @@ public class ArtifactDeploymentService } catch (Exception ex) { - return Result.Failure($"Retry failed for site {siteId}: {ex.Message}"); + return Result.Failure($"Retry failed for site {siteIdentifier}: {ex.Message}"); } } } diff --git a/src/ScadaLink.ManagementService/ManagementActor.cs b/src/ScadaLink.ManagementService/ManagementActor.cs index 150baf4..34a04e4 100644 --- a/src/ScadaLink.ManagementService/ManagementActor.cs +++ b/src/ScadaLink.ManagementService/ManagementActor.cs @@ -103,8 +103,6 @@ public class ManagementActor : ReceiveActor or UpdateSmtpConfigCommand or CreateDataConnectionCommand or UpdateDataConnectionCommand or DeleteDataConnectionCommand - or AssignDataConnectionToSiteCommand - or UnassignDataConnectionFromSiteCommand or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand @@ -176,13 +174,11 @@ public class ManagementActor : ReceiveActor UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username), // Data Connections - ListDataConnectionsCommand => await HandleListDataConnections(sp), + ListDataConnectionsCommand cmd => await HandleListDataConnections(sp, cmd), GetDataConnectionCommand cmd => await HandleGetDataConnection(sp, cmd), CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd, user.Username), UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd, user.Username), DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(sp, cmd, user.Username), - AssignDataConnectionToSiteCommand cmd => await HandleAssignDataConnectionToSite(sp, cmd, user.Username), - UnassignDataConnectionFromSiteCommand cmd => await HandleUnassignDataConnectionFromSite(sp, cmd, user.Username), // External Systems ListExternalSystemsCommand => await HandleListExternalSystems(sp), @@ -676,9 +672,11 @@ public class ManagementActor : ReceiveActor // Data Connection handlers // ======================================================================== - private static async Task HandleListDataConnections(IServiceProvider sp) + private static async Task HandleListDataConnections(IServiceProvider sp, ListDataConnectionsCommand cmd) { var repo = sp.GetRequiredService(); + if (cmd.SiteId.HasValue) + return await repo.GetDataConnectionsBySiteIdAsync(cmd.SiteId.Value); return await repo.GetAllDataConnectionsAsync(); } @@ -691,7 +689,7 @@ public class ManagementActor : ReceiveActor private static async Task HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user) { var repo = sp.GetRequiredService(); - var conn = new DataConnection(cmd.Name, cmd.Protocol) { Configuration = cmd.Configuration }; + var conn = new DataConnection(cmd.Name, cmd.Protocol, cmd.SiteId) { Configuration = cmd.Configuration }; await repo.AddDataConnectionAsync(conn); await repo.SaveChangesAsync(); await AuditAsync(sp, user, "Create", "DataConnection", conn.Id.ToString(), conn.Name, conn); @@ -721,28 +719,6 @@ public class ManagementActor : ReceiveActor return true; } - private static async Task HandleAssignDataConnectionToSite(IServiceProvider sp, AssignDataConnectionToSiteCommand cmd, string user) - { - var repo = sp.GetRequiredService(); - var assignment = new SiteDataConnectionAssignment - { - SiteId = cmd.SiteId, - DataConnectionId = cmd.DataConnectionId - }; - await repo.AddSiteDataConnectionAssignmentAsync(assignment); - await repo.SaveChangesAsync(); - await AuditAsync(sp, user, "Assign", "DataConnection", cmd.DataConnectionId.ToString(), $"Site:{cmd.SiteId}", assignment); - return assignment; - } - - private static async Task HandleUnassignDataConnectionFromSite(IServiceProvider sp, UnassignDataConnectionFromSiteCommand cmd, string user) - { - var repo = sp.GetRequiredService(); - await repo.DeleteSiteDataConnectionAssignmentAsync(cmd.AssignmentId); - await repo.SaveChangesAsync(); - await AuditAsync(sp, user, "Unassign", "DataConnection", cmd.AssignmentId.ToString(), cmd.AssignmentId.ToString(), null); - return true; - } // ======================================================================== // External System handlers @@ -1011,8 +987,7 @@ public class ManagementActor : ReceiveActor private static async Task HandleDeployArtifacts(IServiceProvider sp, MgmtDeployArtifactsCommand cmd, string user) { var svc = sp.GetRequiredService(); - var command = await svc.BuildDeployArtifactsCommandAsync(); - var result = await svc.DeployToAllSitesAsync(command, user); + var result = await svc.DeployToAllSitesAsync(user); return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error); diff --git a/src/ScadaLink.TemplateEngine/Services/SiteService.cs b/src/ScadaLink.TemplateEngine/Services/SiteService.cs index 87994b7..e1225a5 100644 --- a/src/ScadaLink.TemplateEngine/Services/SiteService.cs +++ b/src/ScadaLink.TemplateEngine/Services/SiteService.cs @@ -8,9 +8,7 @@ namespace ScadaLink.TemplateEngine.Services; /// /// Site and data connection management. /// - Site CRUD (name, identifier, description) -/// - Data connection CRUD (name, protocol, config) -/// - Assign connections to sites -/// - Connection names not standardized across sites +/// - Data connection CRUD (name, protocol, config) — each connection belongs to exactly one site /// - Audit logging /// public class SiteService @@ -98,7 +96,7 @@ public class SiteService // --- Data Connection CRUD --- public async Task> CreateDataConnectionAsync( - string name, string protocol, string? configuration, string user, + int siteId, string name, string protocol, string? configuration, string user, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(name)) @@ -106,7 +104,7 @@ public class SiteService if (string.IsNullOrWhiteSpace(protocol)) return Result.Failure("Protocol is required."); - var connection = new DataConnection(name, protocol) { Configuration = configuration }; + var connection = new DataConnection(name, protocol, siteId) { Configuration = configuration }; await _repository.AddDataConnectionAsync(connection, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); @@ -151,56 +149,4 @@ public class SiteService return Result.Success(true); } - - // --- Site-Connection Assignment --- - - public async Task> AssignConnectionToSiteAsync( - int siteId, int dataConnectionId, string user, - CancellationToken cancellationToken = default) - { - var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken); - if (site == null) - return Result.Failure($"Site with ID {siteId} not found."); - - var connection = await _repository.GetDataConnectionByIdAsync(dataConnectionId, cancellationToken); - if (connection == null) - return Result.Failure($"Data connection with ID {dataConnectionId} not found."); - - // Check if assignment already exists - var existing = await _repository.GetSiteDataConnectionAssignmentAsync(siteId, dataConnectionId, cancellationToken); - if (existing != null) - return Result.Failure( - $"Data connection '{connection.Name}' is already assigned to site '{site.Name}'."); - - var assignment = new SiteDataConnectionAssignment - { - SiteId = siteId, - DataConnectionId = dataConnectionId - }; - - await _repository.AddSiteDataConnectionAssignmentAsync(assignment, cancellationToken); - await _repository.SaveChangesAsync(cancellationToken); - - await _auditService.LogAsync(user, "AssignConnection", "SiteDataConnectionAssignment", - assignment.Id.ToString(), $"{site.Name}/{connection.Name}", assignment, cancellationToken); - - return Result.Success(assignment); - } - - public async Task> RemoveConnectionFromSiteAsync( - int siteId, int dataConnectionId, string user, - CancellationToken cancellationToken = default) - { - var assignment = await _repository.GetSiteDataConnectionAssignmentAsync(siteId, dataConnectionId, cancellationToken); - if (assignment == null) - return Result.Failure("Assignment not found."); - - await _repository.DeleteSiteDataConnectionAssignmentAsync(assignment.Id, cancellationToken); - await _repository.SaveChangesAsync(cancellationToken); - - await _auditService.LogAsync(user, "RemoveConnection", "SiteDataConnectionAssignment", - assignment.Id.ToString(), $"Site:{siteId}/Conn:{dataConnectionId}", null, cancellationToken); - - return Result.Success(true); - } } diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/UnitTest1.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/UnitTest1.cs index 78ffe44..9b0623f 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/UnitTest1.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/UnitTest1.cs @@ -45,7 +45,6 @@ public class DbContextTests : IDisposable Assert.NotNull(_context.Areas); Assert.NotNull(_context.Sites); Assert.NotNull(_context.DataConnections); - Assert.NotNull(_context.SiteDataConnectionAssignments); Assert.NotNull(_context.DeploymentRecords); Assert.NotNull(_context.SystemArtifactDeploymentRecords); Assert.NotNull(_context.ExternalSystemDefinitions); @@ -133,9 +132,11 @@ public class DbContextTests : IDisposable { var site = new Site("Site1", "SITE-001"); var template = new Template("Template1"); - var dataConn = new DataConnection("OpcConn", "OpcUa"); _context.Sites.Add(site); _context.Templates.Add(template); + _context.SaveChanges(); + + var dataConn = new DataConnection("OpcConn", "OpcUa", site.Id); _context.DataConnections.Add(dataConn); _context.SaveChanges(); @@ -300,19 +301,18 @@ public class DbContextTests : IDisposable } [Fact] - public void SiteDataConnectionAssignment_CreatesBothForeignKeys() + public void DataConnection_BelongsToSite() { var site = new Site("Site1", "SITE-001"); - var conn = new DataConnection("OpcConn", "OpcUa"); _context.Sites.Add(site); + _context.SaveChanges(); + + var conn = new DataConnection("OpcConn", "OpcUa", site.Id); _context.DataConnections.Add(conn); _context.SaveChanges(); - var assignment = new SiteDataConnectionAssignment { SiteId = site.Id, DataConnectionId = conn.Id }; - _context.SiteDataConnectionAssignments.Add(assignment); - _context.SaveChanges(); - - Assert.Single(_context.SiteDataConnectionAssignments.ToList()); + var loaded = _context.DataConnections.Single(c => c.Name == "OpcConn"); + Assert.Equal(site.Id, loaded.SiteId); } [Fact] diff --git a/tests/ScadaLink.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs b/tests/ScadaLink.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs index 5dde1d9..52b083f 100644 --- a/tests/ScadaLink.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs +++ b/tests/ScadaLink.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs @@ -37,9 +37,8 @@ public class ArtifactDeploymentServiceTests _siteRepo.GetAllSitesAsync().Returns(new List()); var service = CreateService(); - var command = CreateCommand(); - var result = await service.DeployToAllSitesAsync(command, "admin"); + var result = await service.DeployToAllSitesAsync("admin"); Assert.True(result.IsFailure); Assert.Contains("No sites", result.Error); diff --git a/tests/ScadaLink.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs b/tests/ScadaLink.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs index cbdaefb..32562c1 100644 --- a/tests/ScadaLink.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs +++ b/tests/ScadaLink.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs @@ -200,7 +200,7 @@ public class FlatteningServiceTests var connections = new Dictionary { - [100] = new("OPC-Server1", "OpcUa") { Id = 100, Configuration = "opc.tcp://localhost:4840" } + [100] = new("OPC-Server1", "OpcUa", 1) { Id = 100, Configuration = "opc.tcp://localhost:4840" } }; var result = _sut.Flatten( diff --git a/tests/ScadaLink.TemplateEngine.Tests/Services/SiteServiceTests.cs b/tests/ScadaLink.TemplateEngine.Tests/Services/SiteServiceTests.cs index e282d44..01c17aa 100644 --- a/tests/ScadaLink.TemplateEngine.Tests/Services/SiteServiceTests.cs +++ b/tests/ScadaLink.TemplateEngine.Tests/Services/SiteServiceTests.cs @@ -93,44 +93,12 @@ public class SiteServiceTests _repoMock.Setup(r => r.SaveChangesAsync(It.IsAny())) .ReturnsAsync(1); - var result = await _sut.CreateDataConnectionAsync("OPC-Server1", "OpcUa", "{\"url\":\"opc.tcp://localhost\"}", "admin"); + var result = await _sut.CreateDataConnectionAsync(1, "OPC-Server1", "OpcUa", "{\"url\":\"opc.tcp://localhost\"}", "admin"); Assert.True(result.IsSuccess); Assert.Equal("OPC-Server1", result.Value.Name); Assert.Equal("OpcUa", result.Value.Protocol); - } - - [Fact] - public async Task AssignConnectionToSite_AlreadyAssigned_ReturnsFailure() - { - _repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny())) - .ReturnsAsync(new Site("S", "S1") { Id = 1 }); - _repoMock.Setup(r => r.GetDataConnectionByIdAsync(100, It.IsAny())) - .ReturnsAsync(new DataConnection("Conn", "OpcUa") { Id = 100 }); - _repoMock.Setup(r => r.GetSiteDataConnectionAssignmentAsync(1, 100, It.IsAny())) - .ReturnsAsync(new SiteDataConnectionAssignment { Id = 1, SiteId = 1, DataConnectionId = 100 }); - - var result = await _sut.AssignConnectionToSiteAsync(1, 100, "admin"); - - Assert.True(result.IsFailure); - Assert.Contains("already assigned", result.Error); - } - - [Fact] - public async Task AssignConnectionToSite_Valid_Success() - { - _repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny())) - .ReturnsAsync(new Site("S", "S1") { Id = 1 }); - _repoMock.Setup(r => r.GetDataConnectionByIdAsync(100, It.IsAny())) - .ReturnsAsync(new DataConnection("Conn", "OpcUa") { Id = 100 }); - _repoMock.Setup(r => r.GetSiteDataConnectionAssignmentAsync(1, 100, It.IsAny())) - .ReturnsAsync((SiteDataConnectionAssignment?)null); - _repoMock.Setup(r => r.SaveChangesAsync(It.IsAny())) - .ReturnsAsync(1); - - var result = await _sut.AssignConnectionToSiteAsync(1, 100, "admin"); - - Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value.SiteId); } [Fact]