refactor: simplify data connections from many-to-many site assignment to direct site ownership

Replace SiteDataConnectionAssignment join table with a direct SiteId FK on DataConnection,
simplifying the data model, repositories, UI, CLI, and deployment service.
This commit is contained in:
Joseph Doherty
2026-03-21 21:07:10 -04:00
parent cd6efeea90
commit 970d0a5cb3
25 changed files with 1543 additions and 490 deletions

View File

@@ -15,8 +15,6 @@ public static class DataConnectionCommands
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(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; return command;
} }
@@ -60,49 +58,41 @@ public static class DataConnectionCommands
return cmd; return cmd;
} }
private static Command BuildUnassign(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var cmd = new Command("list") { Description = "List all data connections" }; var siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
var cmd = new Command("list") { Description = "List data connections" };
cmd.Add(siteIdOption);
cmd.SetAction(async (ParseResult result) => cmd.SetAction(async (ParseResult result) =>
{ {
var siteId = result.GetValue(siteIdOption);
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListDataConnectionsCommand()); result, urlOption, formatOption, usernameOption, passwordOption, new ListDataConnectionsCommand(siteId));
}); });
return cmd; return cmd;
} }
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true }; var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
var protocolOption = new Option<string>("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true }; var protocolOption = new Option<string>("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true };
var configOption = new Option<string?>("--configuration") { Description = "Connection configuration JSON" }; var configOption = new Option<string?>("--configuration") { Description = "Connection configuration JSON" };
var cmd = new Command("create") { Description = "Create a new data connection" }; var cmd = new Command("create") { Description = "Create a new data connection" };
cmd.Add(siteIdOption);
cmd.Add(nameOption); cmd.Add(nameOption);
cmd.Add(protocolOption); cmd.Add(protocolOption);
cmd.Add(configOption); cmd.Add(configOption);
cmd.SetAction(async (ParseResult result) => cmd.SetAction(async (ParseResult result) =>
{ {
var siteId = result.GetValue(siteIdOption);
var name = result.GetValue(nameOption)!; var name = result.GetValue(nameOption)!;
var protocol = result.GetValue(protocolOption)!; var protocol = result.GetValue(protocolOption)!;
var config = result.GetValue(configOption); var config = result.GetValue(configOption);
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new CreateDataConnectionCommand(name, protocol, config)); new CreateDataConnectionCommand(siteId, name, protocol, config));
}); });
return cmd; return cmd;
} }
@@ -120,23 +110,4 @@ public static class DataConnectionCommands
}); });
return cmd; return cmd;
} }
private static Command BuildAssign(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var connectionIdOption = new Option<int>("--connection-id") { Description = "Data connection ID", Required = true };
var siteIdOption = new Option<int>("--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;
}
} }

View File

@@ -603,22 +603,27 @@ scadalink --url <url> data-connection get --id <int>
#### `data-connection list` #### `data-connection list`
List all configured data connections. List data connections, optionally filtered by site.
```sh ```sh
scadalink --url <url> data-connection list scadalink --url <url> data-connection list [--site-id <int>]
```
#### `data-connection create`
Create a new data connection definition.
```sh
scadalink --url <url> data-connection create --name <string> --protocol <string> [--configuration <json>]
``` ```
| Option | Required | Description | | 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 <url> data-connection create --site-id <int> --name <string> --protocol <string> [--configuration <json>]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--site-id` | yes | Site ID the connection belongs to |
| `--name` | yes | Connection name | | `--name` | yes | Connection name |
| `--protocol` | yes | Protocol identifier (e.g. `OpcUa`) | | `--protocol` | yes | Protocol identifier (e.g. `OpcUa`) |
| `--configuration` | no | Protocol-specific configuration as a JSON string | | `--configuration` | no | Protocol-specific configuration as a JSON string |
@@ -650,32 +655,6 @@ scadalink --url <url> data-connection delete --id <int>
|--------|----------|-------------| |--------|----------|-------------|
| `--id` | yes | Data connection ID | | `--id` | yes | Data connection ID |
#### `data-connection assign`
Assign a data connection to a site.
```sh
scadalink --url <url> data-connection assign --connection-id <int> --site-id <int>
```
| 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 <url> data-connection unassign --connection-id <int> --site-id <int>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--connection-id` | yes | Data connection ID |
| `--site-id` | yes | Site ID |
--- ---
### `external-system` — Manage external HTTP systems ### `external-system` — Manage external HTTP systems

View File

@@ -21,6 +21,26 @@
{ {
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
@if (Id.HasValue)
{
<div class="mb-2">
<label class="form-label small">Site</label>
<input type="text" class="form-control form-control-sm" value="@(_siteName)" disabled />
</div>
}
else
{
<div class="mb-2">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_formSiteId">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name</option>
}
</select>
</div>
}
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">Name</label> <label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" /> <input type="text" class="form-control form-control-sm" @bind="_formName" />
@@ -57,6 +77,9 @@
private bool _loading = true; private bool _loading = true;
private DataConnection? _editingConnection; private DataConnection? _editingConnection;
private List<Site> _sites = new();
private int _formSiteId;
private string _siteName = string.Empty;
private string _formName = string.Empty; private string _formName = string.Empty;
private string _formProtocol = string.Empty; private string _formProtocol = string.Empty;
private string? _formConfiguration; private string? _formConfiguration;
@@ -64,6 +87,8 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
if (Id.HasValue) if (Id.HasValue)
{ {
try try
@@ -71,6 +96,8 @@
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value); _editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
if (_editingConnection != null) if (_editingConnection != null)
{ {
_formSiteId = _editingConnection.SiteId;
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
_formName = _editingConnection.Name; _formName = _editingConnection.Name;
_formProtocol = _editingConnection.Protocol; _formProtocol = _editingConnection.Protocol;
_formConfiguration = _editingConnection.Configuration; _formConfiguration = _editingConnection.Configuration;
@@ -87,6 +114,7 @@
private async Task SaveConnection() private async Task SaveConnection()
{ {
_formError = null; _formError = null;
if (_formSiteId == 0) { _formError = "Site is required."; return; }
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; } if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; } if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; }
@@ -101,7 +129,7 @@
} }
else else
{ {
var conn = new DataConnection(_formName.Trim(), _formProtocol) var conn = new DataConnection(_formName.Trim(), _formProtocol, _formSiteId)
{ {
Configuration = _formConfiguration?.Trim() Configuration = _formConfiguration?.Trim()
}; };

View File

@@ -25,58 +25,14 @@
} }
else else
{ {
@* Assignment form *@
@if (_showAssignForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Assign Connection to Site</h6>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Connection</label>
<select class="form-select form-select-sm" @bind="_assignConnectionId">
<option value="0">Select connection...</option>
@foreach (var conn in _connections)
{
<option value="@conn.Id">@conn.Name (@conn.Protocol)</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_assignSiteId">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name</option>
}
</select>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveAssignment">Assign</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelAssignForm">Cancel</button>
</div>
</div>
@if (_assignError != null)
{
<div class="text-danger small mt-1">@_assignError</div>
}
</div>
</div>
}
<div class="mb-2">
<button class="btn btn-outline-info btn-sm" @onclick="ShowAssignForm">Assign to Site</button>
</div>
<table class="table table-sm table-striped table-hover"> <table class="table table-sm table-striped table-hover">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Name</th> <th>Name</th>
<th>Protocol</th> <th>Protocol</th>
<th>Site</th>
<th>Configuration</th> <th>Configuration</th>
<th>Assigned Sites</th>
<th style="width: 160px;">Actions</th> <th style="width: 160px;">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -93,29 +49,8 @@
<td>@conn.Id</td> <td>@conn.Id</td>
<td>@conn.Name</td> <td>@conn.Name</td>
<td><span class="badge bg-secondary">@conn.Protocol</span></td> <td><span class="badge bg-secondary">@conn.Protocol</span></td>
<td>@(_siteLookup.GetValueOrDefault(conn.SiteId)?.Name ?? $"Site {conn.SiteId}")</td>
<td class="text-muted small text-truncate" style="max-width: 300px;">@(conn.Configuration ?? "—")</td> <td class="text-muted small text-truncate" style="max-width: 300px;">@(conn.Configuration ?? "—")</td>
<td>
@{
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}";
<span class="badge bg-info text-dark me-1">
@siteName
<button type="button" class="btn-close btn-close-white ms-1"
style="font-size: 0.5rem;"
@onclick="() => RemoveAssignment(assignment)"></button>
</span>
}
}
else
{
<span class="text-muted small">None</span>
}
</td>
<td> <td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" <button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick='() => NavigationManager.NavigateTo($"/admin/data-connections/{conn.Id}/edit")'>Edit</button> @onclick='() => NavigationManager.NavigateTo($"/admin/data-connections/{conn.Id}/edit")'>Edit</button>
@@ -131,16 +66,10 @@
@code { @code {
private List<DataConnection> _connections = new(); private List<DataConnection> _connections = new();
private List<Site> _sites = new(); private Dictionary<int, Site> _siteLookup = new();
private Dictionary<int, List<SiteDataConnectionAssignment>> _connectionSites = new();
private bool _loading = true; private bool _loading = true;
private string? _errorMessage; private string? _errorMessage;
private bool _showAssignForm;
private int _assignConnectionId;
private int _assignSiteId;
private string? _assignError;
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!; private ConfirmDialog _confirmDialog = default!;
@@ -155,24 +84,9 @@
_errorMessage = null; _errorMessage = null;
try try
{ {
_sites = (await SiteRepository.GetAllSitesAsync()).ToList(); var sites = await SiteRepository.GetAllSitesAsync();
_siteLookup = sites.ToDictionary(s => s.Id);
_connections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList(); _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<SiteDataConnectionAssignment>();
var assignment = await SiteRepository.GetSiteDataConnectionAssignmentAsync(site.Id, conn.Id);
if (assignment != null)
_connectionSites[conn.Id].Add(assignment);
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -199,58 +113,4 @@
_toast.ShowError($"Delete failed: {ex.Message}"); _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}");
}
}
} }

View File

@@ -178,10 +178,9 @@
_deploying = true; _deploying = true;
try try
{ {
var command = await ArtifactDeploymentService.BuildDeployArtifactsCommandAsync();
var user = await GetCurrentUserAsync(); var user = await GetCurrentUserAsync();
var result = await ArtifactDeploymentService.RetryForSiteAsync( var result = await ArtifactDeploymentService.RetryForSiteAsync(
site.SiteIdentifier, command, user); site.Id, site.SiteIdentifier, user);
if (result.IsSuccess) if (result.IsSuccess)
_toast.ShowSuccess($"Artifacts deployed to '{site.Name}'."); _toast.ShowSuccess($"Artifacts deployed to '{site.Name}'.");
@@ -203,9 +202,8 @@
_deploying = true; _deploying = true;
try try
{ {
var command = await ArtifactDeploymentService.BuildDeployArtifactsCommandAsync();
var user = await GetCurrentUserAsync(); var user = await GetCurrentUserAsync();
var result = await ArtifactDeploymentService.DeployToAllSitesAsync(command, user); var result = await ArtifactDeploymentService.DeployToAllSitesAsync(user);
if (result.IsSuccess) if (result.IsSuccess)
{ {

View File

@@ -697,13 +697,8 @@
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId); var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId);
_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).ToList(); _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(); _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 // Load existing bindings
var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(inst.Id); var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(inst.Id);

View File

@@ -3,13 +3,15 @@ namespace ScadaLink.Commons.Entities.Sites;
public class DataConnection public class DataConnection
{ {
public int Id { get; set; } public int Id { get; set; }
public int SiteId { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string Protocol { get; set; } public string Protocol { get; set; }
public string? Configuration { 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)); Name = name ?? throw new ArgumentNullException(nameof(name));
Protocol = protocol ?? throw new ArgumentNullException(nameof(protocol)); Protocol = protocol ?? throw new ArgumentNullException(nameof(protocol));
SiteId = siteId;
} }
} }

View File

@@ -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; }
}

View File

@@ -10,7 +10,7 @@ public interface ICentralUiRepository
{ {
Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default); Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default); Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<SiteDataConnectionAssignment>> GetAllSiteDataConnectionAssignmentsAsync(CancellationToken cancellationToken = default); Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<Template>> GetTemplateTreeAsync(CancellationToken cancellationToken = default); Task<IReadOnlyList<Template>> GetTemplateTreeAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<Instance>> GetInstancesFilteredAsync(int? siteId = null, int? templateId = null, string? searchTerm = null, CancellationToken cancellationToken = default); Task<IReadOnlyList<Instance>> GetInstancesFilteredAsync(int? siteId = null, int? templateId = null, string? searchTerm = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DeploymentRecord>> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default); Task<IReadOnlyList<DeploymentRecord>> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default);

View File

@@ -24,11 +24,6 @@ public interface ISiteRepository
Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default); Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default); Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default);
// Site-Connection Assignments
Task<SiteDataConnectionAssignment?> 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) // Instances (for deletion constraint checks)
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default); Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);

View File

@@ -1,9 +1,7 @@
namespace ScadaLink.Commons.Messages.Management; namespace ScadaLink.Commons.Messages.Management;
public record ListDataConnectionsCommand; public record ListDataConnectionsCommand(int? SiteId = null);
public record GetDataConnectionCommand(int DataConnectionId); 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 UpdateDataConnectionCommand(int DataConnectionId, string Name, string Protocol, string? Configuration);
public record DeleteDataConnectionCommand(int DataConnectionId); public record DeleteDataConnectionCommand(int DataConnectionId);
public record AssignDataConnectionToSiteCommand(int DataConnectionId, int SiteId);
public record UnassignDataConnectionFromSiteCommand(int AssignmentId);

View File

@@ -46,26 +46,11 @@ public class DataConnectionConfiguration : IEntityTypeConfiguration<DataConnecti
builder.Property(d => d.Configuration) builder.Property(d => d.Configuration)
.HasMaxLength(4000); .HasMaxLength(4000);
builder.HasIndex(d => d.Name).IsUnique();
}
}
public class SiteDataConnectionAssignmentConfiguration : IEntityTypeConfiguration<SiteDataConnectionAssignment>
{
public void Configure(EntityTypeBuilder<SiteDataConnectionAssignment> builder)
{
builder.HasKey(a => a.Id);
builder.HasOne<Site>() builder.HasOne<Site>()
.WithMany() .WithMany()
.HasForeignKey(a => a.SiteId) .HasForeignKey(d => d.SiteId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Restrict);
builder.HasOne<DataConnection>() builder.HasIndex(d => new { d.SiteId, d.Name }).IsUnique();
.WithMany()
.HasForeignKey(a => a.DataConnectionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(a => new { a.SiteId, a.DataConnectionId }).IsUnique();
} }
} }

View File

@@ -0,0 +1,184 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class AddSiteIdToDataConnections : Migration
{
/// <inheritdoc />
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<int>(
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<int>(
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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Recreate SiteDataConnectionAssignments table
migrationBuilder.CreateTable(
name: "SiteDataConnectionAssignments",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DataConnectionId = table.Column<int>(type: "int", nullable: false),
SiteId = table.Column<int>(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");
}
}
}

View File

@@ -766,9 +766,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("nvarchar(50)"); .HasColumnType("nvarchar(50)");
b.Property<int>("SiteId")
.HasColumnType("int");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Name") b.HasIndex("SiteId", "Name")
.IsUnique(); .IsUnique();
b.ToTable("DataConnections"); b.ToTable("DataConnections");
@@ -821,30 +824,6 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.ToTable("Sites"); b.ToTable("Sites");
}); });
modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.SiteDataConnectionAssignment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DataConnectionId")
.HasColumnType("int");
b.Property<int>("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 => modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1153,18 +1132,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsRequired(); .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) b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null)
.WithMany() .WithMany()
.HasForeignKey("SiteId") .HasForeignKey("SiteId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
}); });

View File

@@ -27,17 +27,18 @@ public class CentralUiRepository : ICentralUiRepository
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{ {
return await _context.SiteDataConnectionAssignments return await _context.DataConnections
.AsNoTracking() .AsNoTracking()
.Where(a => a.SiteId == siteId) .Where(d => d.SiteId == siteId)
.Join(_context.DataConnections, a => a.DataConnectionId, d => d.Id, (_, d) => d) .OrderBy(d => d.Name)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
public async Task<IReadOnlyList<SiteDataConnectionAssignment>> GetAllSiteDataConnectionAssignmentsAsync(CancellationToken cancellationToken = default) public async Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default)
{ {
return await _context.SiteDataConnectionAssignments return await _context.DataConnections
.AsNoTracking() .AsNoTracking()
.OrderBy(d => d.Name)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }

View File

@@ -76,13 +76,8 @@ public class SiteRepository : ISiteRepository
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<DataConnection>> 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 return await _dbContext.DataConnections
.Where(c => connectionIds.Contains(c.Id)) .Where(c => c.SiteId == siteId)
.OrderBy(c => c.Name) .OrderBy(c => c.Name)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
@@ -107,43 +102,13 @@ public class SiteRepository : ISiteRepository
} }
else else
{ {
var stub = new DataConnection("stub", "stub") { Id = id }; var stub = new DataConnection("stub", "stub", 0) { Id = id };
_dbContext.DataConnections.Attach(stub); _dbContext.DataConnections.Attach(stub);
_dbContext.DataConnections.Remove(stub); _dbContext.DataConnections.Remove(stub);
} }
return Task.CompletedTask; return Task.CompletedTask;
} }
// --- Site-Connection Assignments ---
public async Task<SiteDataConnectionAssignment?> 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) --- // --- Instances (for deletion constraint checks) ---
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)

View File

@@ -35,7 +35,6 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
// Sites // Sites
public DbSet<Site> Sites => Set<Site>(); public DbSet<Site> Sites => Set<Site>();
public DbSet<DataConnection> DataConnections => Set<DataConnection>(); public DbSet<DataConnection> DataConnections => Set<DataConnection>();
public DbSet<SiteDataConnectionAssignment> SiteDataConnectionAssignments => Set<SiteDataConnectionAssignment>();
// Deployment // Deployment
public DbSet<DeploymentRecord> DeploymentRecords => Set<DeploymentRecord>(); public DbSet<DeploymentRecord> DeploymentRecords => Set<DeploymentRecord>();

View File

@@ -55,16 +55,18 @@ public class ArtifactDeploymentService
} }
/// <summary> /// <summary>
/// Collects all artifact types from repositories and builds a <see cref="DeployArtifactsCommand"/>. /// Collects all artifact types from repositories and builds a <see cref="DeployArtifactsCommand"/>
/// scoped to a specific site's data connections.
/// </summary> /// </summary>
public async Task<DeployArtifactsCommand> BuildDeployArtifactsCommandAsync( public async Task<DeployArtifactsCommand> BuildDeployArtifactsCommandAsync(
int siteId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var sharedScripts = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken); var sharedScripts = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
var externalSystems = await _externalSystemRepo.GetAllExternalSystemsAsync(cancellationToken); var externalSystems = await _externalSystemRepo.GetAllExternalSystemsAsync(cancellationToken);
var dbConnections = await _externalSystemRepo.GetAllDatabaseConnectionsAsync(cancellationToken); var dbConnections = await _externalSystemRepo.GetAllDatabaseConnectionsAsync(cancellationToken);
var notificationLists = await _notificationRepo.GetAllNotificationListsAsync(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); var smtpConfigurations = await _notificationRepo.GetAllSmtpConfigurationsAsync(cancellationToken);
// Map shared scripts // Map shared scripts
@@ -120,10 +122,10 @@ public class ArtifactDeploymentService
} }
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
public async Task<Result<ArtifactDeploymentSummary>> DeployToAllSitesAsync( public async Task<Result<ArtifactDeploymentSummary>> DeployToAllSitesAsync(
DeployArtifactsCommand command,
string user, string user,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -131,9 +133,17 @@ public class ArtifactDeploymentService
if (sites.Count == 0) if (sites.Count == 0)
return Result<ArtifactDeploymentSummary>.Failure("No sites configured."); return Result<ArtifactDeploymentSummary>.Failure("No sites configured.");
var deploymentId = Guid.NewGuid().ToString("N");
var perSiteResults = new Dictionary<string, SiteArtifactResult>(); var perSiteResults = new Dictionary<string, SiteArtifactResult>();
// Deploy to each site with per-site timeout // Build per-site commands sequentially (DbContext is not thread-safe)
var siteCommands = new Dictionary<int, DeployArtifactsCommand>();
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 => var tasks = sites.Select(async site =>
{ {
try try
@@ -141,6 +151,8 @@ public class ArtifactDeploymentService
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.ArtifactDeploymentTimeoutPerSite); cts.CancelAfter(_options.ArtifactDeploymentTimeoutPerSite);
var command = siteCommands[site.Id];
_logger.LogInformation( _logger.LogInformation(
"Deploying artifacts to site {SiteId} ({SiteName}), deploymentId={DeploymentId}", "Deploying artifacts to site {SiteId} ({SiteName}), deploymentId={DeploymentId}",
site.SiteIdentifier, site.Name, command.DeploymentId); site.SiteIdentifier, site.Name, command.DeploymentId);
@@ -188,13 +200,13 @@ public class ArtifactDeploymentService
await _deploymentRepo.SaveChangesAsync(cancellationToken); await _deploymentRepo.SaveChangesAsync(cancellationToken);
var summary = new ArtifactDeploymentSummary( var summary = new ArtifactDeploymentSummary(
command.DeploymentId, deploymentId,
results.ToList(), results.ToList(),
results.Count(r => r.Success), results.Count(r => r.Success),
results.Count(r => !r.Success)); results.Count(r => !r.Success));
await _auditService.LogAsync(user, "DeployArtifacts", "SystemArtifact", await _auditService.LogAsync(user, "DeployArtifacts", "SystemArtifact",
command.DeploymentId, "Artifacts", deploymentId, "Artifacts",
new { summary.SuccessCount, summary.FailureCount }, new { summary.SuccessCount, summary.FailureCount },
cancellationToken); cancellationToken);
@@ -205,8 +217,8 @@ public class ArtifactDeploymentService
/// WP-7: Retry artifact deployment to a specific site that previously failed. /// WP-7: Retry artifact deployment to a specific site that previously failed.
/// </summary> /// </summary>
public async Task<Result<SiteArtifactResult>> RetryForSiteAsync( public async Task<Result<SiteArtifactResult>> RetryForSiteAsync(
string siteId, int siteDbId,
DeployArtifactsCommand command, string siteIdentifier,
string user, string user,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -215,12 +227,13 @@ public class ArtifactDeploymentService
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.ArtifactDeploymentTimeoutPerSite); 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", await _auditService.LogAsync(user, "RetryArtifactDeployment", "SystemArtifact",
command.DeploymentId, siteId, new { response.Success }, cancellationToken); command.DeploymentId, siteIdentifier, new { response.Success }, cancellationToken);
return response.Success return response.Success
? Result<SiteArtifactResult>.Success(result) ? Result<SiteArtifactResult>.Success(result)
@@ -228,7 +241,7 @@ public class ArtifactDeploymentService
} }
catch (Exception ex) catch (Exception ex)
{ {
return Result<SiteArtifactResult>.Failure($"Retry failed for site {siteId}: {ex.Message}"); return Result<SiteArtifactResult>.Failure($"Retry failed for site {siteIdentifier}: {ex.Message}");
} }
} }
} }

View File

@@ -103,8 +103,6 @@ public class ManagementActor : ReceiveActor
or UpdateSmtpConfigCommand or UpdateSmtpConfigCommand
or CreateDataConnectionCommand or UpdateDataConnectionCommand or CreateDataConnectionCommand or UpdateDataConnectionCommand
or DeleteDataConnectionCommand or DeleteDataConnectionCommand
or AssignDataConnectionToSiteCommand
or UnassignDataConnectionFromSiteCommand
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand
or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
@@ -176,13 +174,11 @@ public class ManagementActor : ReceiveActor
UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username), UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username),
// Data Connections // Data Connections
ListDataConnectionsCommand => await HandleListDataConnections(sp), ListDataConnectionsCommand cmd => await HandleListDataConnections(sp, cmd),
GetDataConnectionCommand cmd => await HandleGetDataConnection(sp, cmd), GetDataConnectionCommand cmd => await HandleGetDataConnection(sp, cmd),
CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd, user.Username), CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd, user.Username),
UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd, user.Username), UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd, user.Username),
DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(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 // External Systems
ListExternalSystemsCommand => await HandleListExternalSystems(sp), ListExternalSystemsCommand => await HandleListExternalSystems(sp),
@@ -676,9 +672,11 @@ public class ManagementActor : ReceiveActor
// Data Connection handlers // Data Connection handlers
// ======================================================================== // ========================================================================
private static async Task<object?> HandleListDataConnections(IServiceProvider sp) private static async Task<object?> HandleListDataConnections(IServiceProvider sp, ListDataConnectionsCommand cmd)
{ {
var repo = sp.GetRequiredService<ISiteRepository>(); var repo = sp.GetRequiredService<ISiteRepository>();
if (cmd.SiteId.HasValue)
return await repo.GetDataConnectionsBySiteIdAsync(cmd.SiteId.Value);
return await repo.GetAllDataConnectionsAsync(); return await repo.GetAllDataConnectionsAsync();
} }
@@ -691,7 +689,7 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user) private static async Task<object?> HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user)
{ {
var repo = sp.GetRequiredService<ISiteRepository>(); var repo = sp.GetRequiredService<ISiteRepository>();
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.AddDataConnectionAsync(conn);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "DataConnection", conn.Id.ToString(), conn.Name, conn); await AuditAsync(sp, user, "Create", "DataConnection", conn.Id.ToString(), conn.Name, conn);
@@ -721,28 +719,6 @@ public class ManagementActor : ReceiveActor
return true; return true;
} }
private static async Task<object?> HandleAssignDataConnectionToSite(IServiceProvider sp, AssignDataConnectionToSiteCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISiteRepository>();
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<object?> HandleUnassignDataConnectionFromSite(IServiceProvider sp, UnassignDataConnectionFromSiteCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISiteRepository>();
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 // External System handlers
@@ -1011,8 +987,7 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleDeployArtifacts(IServiceProvider sp, MgmtDeployArtifactsCommand cmd, string user) private static async Task<object?> HandleDeployArtifacts(IServiceProvider sp, MgmtDeployArtifactsCommand cmd, string user)
{ {
var svc = sp.GetRequiredService<ArtifactDeploymentService>(); var svc = sp.GetRequiredService<ArtifactDeploymentService>();
var command = await svc.BuildDeployArtifactsCommandAsync(); var result = await svc.DeployToAllSitesAsync(user);
var result = await svc.DeployToAllSitesAsync(command, user);
return result.IsSuccess return result.IsSuccess
? result.Value ? result.Value
: throw new InvalidOperationException(result.Error); : throw new InvalidOperationException(result.Error);

View File

@@ -8,9 +8,7 @@ namespace ScadaLink.TemplateEngine.Services;
/// <summary> /// <summary>
/// Site and data connection management. /// Site and data connection management.
/// - Site CRUD (name, identifier, description) /// - Site CRUD (name, identifier, description)
/// - Data connection CRUD (name, protocol, config) /// - Data connection CRUD (name, protocol, config) — each connection belongs to exactly one site
/// - Assign connections to sites
/// - Connection names not standardized across sites
/// - Audit logging /// - Audit logging
/// </summary> /// </summary>
public class SiteService public class SiteService
@@ -98,7 +96,7 @@ public class SiteService
// --- Data Connection CRUD --- // --- Data Connection CRUD ---
public async Task<Result<DataConnection>> CreateDataConnectionAsync( public async Task<Result<DataConnection>> CreateDataConnectionAsync(
string name, string protocol, string? configuration, string user, int siteId, string name, string protocol, string? configuration, string user,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
@@ -106,7 +104,7 @@ public class SiteService
if (string.IsNullOrWhiteSpace(protocol)) if (string.IsNullOrWhiteSpace(protocol))
return Result<DataConnection>.Failure("Protocol is required."); return Result<DataConnection>.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.AddDataConnectionAsync(connection, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken); await _repository.SaveChangesAsync(cancellationToken);
@@ -151,56 +149,4 @@ public class SiteService
return Result<bool>.Success(true); return Result<bool>.Success(true);
} }
// --- Site-Connection Assignment ---
public async Task<Result<SiteDataConnectionAssignment>> AssignConnectionToSiteAsync(
int siteId, int dataConnectionId, string user,
CancellationToken cancellationToken = default)
{
var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken);
if (site == null)
return Result<SiteDataConnectionAssignment>.Failure($"Site with ID {siteId} not found.");
var connection = await _repository.GetDataConnectionByIdAsync(dataConnectionId, cancellationToken);
if (connection == null)
return Result<SiteDataConnectionAssignment>.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<SiteDataConnectionAssignment>.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<SiteDataConnectionAssignment>.Success(assignment);
}
public async Task<Result<bool>> RemoveConnectionFromSiteAsync(
int siteId, int dataConnectionId, string user,
CancellationToken cancellationToken = default)
{
var assignment = await _repository.GetSiteDataConnectionAssignmentAsync(siteId, dataConnectionId, cancellationToken);
if (assignment == null)
return Result<bool>.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<bool>.Success(true);
}
} }

View File

@@ -45,7 +45,6 @@ public class DbContextTests : IDisposable
Assert.NotNull(_context.Areas); Assert.NotNull(_context.Areas);
Assert.NotNull(_context.Sites); Assert.NotNull(_context.Sites);
Assert.NotNull(_context.DataConnections); Assert.NotNull(_context.DataConnections);
Assert.NotNull(_context.SiteDataConnectionAssignments);
Assert.NotNull(_context.DeploymentRecords); Assert.NotNull(_context.DeploymentRecords);
Assert.NotNull(_context.SystemArtifactDeploymentRecords); Assert.NotNull(_context.SystemArtifactDeploymentRecords);
Assert.NotNull(_context.ExternalSystemDefinitions); Assert.NotNull(_context.ExternalSystemDefinitions);
@@ -133,9 +132,11 @@ public class DbContextTests : IDisposable
{ {
var site = new Site("Site1", "SITE-001"); var site = new Site("Site1", "SITE-001");
var template = new Template("Template1"); var template = new Template("Template1");
var dataConn = new DataConnection("OpcConn", "OpcUa");
_context.Sites.Add(site); _context.Sites.Add(site);
_context.Templates.Add(template); _context.Templates.Add(template);
_context.SaveChanges();
var dataConn = new DataConnection("OpcConn", "OpcUa", site.Id);
_context.DataConnections.Add(dataConn); _context.DataConnections.Add(dataConn);
_context.SaveChanges(); _context.SaveChanges();
@@ -300,19 +301,18 @@ public class DbContextTests : IDisposable
} }
[Fact] [Fact]
public void SiteDataConnectionAssignment_CreatesBothForeignKeys() public void DataConnection_BelongsToSite()
{ {
var site = new Site("Site1", "SITE-001"); var site = new Site("Site1", "SITE-001");
var conn = new DataConnection("OpcConn", "OpcUa");
_context.Sites.Add(site); _context.Sites.Add(site);
_context.SaveChanges();
var conn = new DataConnection("OpcConn", "OpcUa", site.Id);
_context.DataConnections.Add(conn); _context.DataConnections.Add(conn);
_context.SaveChanges(); _context.SaveChanges();
var assignment = new SiteDataConnectionAssignment { SiteId = site.Id, DataConnectionId = conn.Id }; var loaded = _context.DataConnections.Single(c => c.Name == "OpcConn");
_context.SiteDataConnectionAssignments.Add(assignment); Assert.Equal(site.Id, loaded.SiteId);
_context.SaveChanges();
Assert.Single(_context.SiteDataConnectionAssignments.ToList());
} }
[Fact] [Fact]

View File

@@ -37,9 +37,8 @@ public class ArtifactDeploymentServiceTests
_siteRepo.GetAllSitesAsync().Returns(new List<Site>()); _siteRepo.GetAllSitesAsync().Returns(new List<Site>());
var service = CreateService(); var service = CreateService();
var command = CreateCommand();
var result = await service.DeployToAllSitesAsync(command, "admin"); var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsFailure); Assert.True(result.IsFailure);
Assert.Contains("No sites", result.Error); Assert.Contains("No sites", result.Error);

View File

@@ -200,7 +200,7 @@ public class FlatteningServiceTests
var connections = new Dictionary<int, DataConnection> var connections = new Dictionary<int, DataConnection>
{ {
[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( var result = _sut.Flatten(

View File

@@ -93,44 +93,12 @@ public class SiteServiceTests
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())) _repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1); .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.True(result.IsSuccess);
Assert.Equal("OPC-Server1", result.Value.Name); Assert.Equal("OPC-Server1", result.Value.Name);
Assert.Equal("OpcUa", result.Value.Protocol); Assert.Equal("OpcUa", result.Value.Protocol);
} Assert.Equal(1, result.Value.SiteId);
[Fact]
public async Task AssignConnectionToSite_AlreadyAssigned_ReturnsFailure()
{
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Site("S", "S1") { Id = 1 });
_repoMock.Setup(r => r.GetDataConnectionByIdAsync(100, It.IsAny<CancellationToken>()))
.ReturnsAsync(new DataConnection("Conn", "OpcUa") { Id = 100 });
_repoMock.Setup(r => r.GetSiteDataConnectionAssignmentAsync(1, 100, It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(new Site("S", "S1") { Id = 1 });
_repoMock.Setup(r => r.GetDataConnectionByIdAsync(100, It.IsAny<CancellationToken>()))
.ReturnsAsync(new DataConnection("Conn", "OpcUa") { Id = 100 });
_repoMock.Setup(r => r.GetSiteDataConnectionAssignmentAsync(1, 100, It.IsAny<CancellationToken>()))
.ReturnsAsync((SiteDataConnectionAssignment?)null);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.AssignConnectionToSiteAsync(1, 100, "admin");
Assert.True(result.IsSuccess);
} }
[Fact] [Fact]