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:
@@ -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<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)
|
||||
{
|
||||
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) =>
|
||||
{
|
||||
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<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 protocolOption = new Option<string>("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true };
|
||||
var configOption = new Option<string?>("--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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,22 +603,27 @@ scadalink --url <url> data-connection get --id <int>
|
||||
|
||||
#### `data-connection list`
|
||||
|
||||
List all configured data connections.
|
||||
List data connections, optionally filtered by site.
|
||||
|
||||
```sh
|
||||
scadalink --url <url> data-connection list
|
||||
```
|
||||
|
||||
#### `data-connection create`
|
||||
|
||||
Create a new data connection definition.
|
||||
|
||||
```sh
|
||||
scadalink --url <url> data-connection create --name <string> --protocol <string> [--configuration <json>]
|
||||
scadalink --url <url> data-connection list [--site-id <int>]
|
||||
```
|
||||
|
||||
| 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 |
|
||||
| `--protocol` | yes | Protocol identifier (e.g. `OpcUa`) |
|
||||
| `--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 |
|
||||
|
||||
#### `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
|
||||
|
||||
@@ -21,6 +21,26 @@
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<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">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
@@ -57,6 +77,9 @@
|
||||
|
||||
private bool _loading = true;
|
||||
private DataConnection? _editingConnection;
|
||||
private List<Site> _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()
|
||||
};
|
||||
|
||||
@@ -25,58 +25,14 @@
|
||||
}
|
||||
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">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Protocol</th>
|
||||
<th>Site</th>
|
||||
<th>Configuration</th>
|
||||
<th>Assigned Sites</th>
|
||||
<th style="width: 160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -93,29 +49,8 @@
|
||||
<td>@conn.Id</td>
|
||||
<td>@conn.Name</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>
|
||||
@{
|
||||
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>
|
||||
<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>
|
||||
@@ -131,16 +66,10 @@
|
||||
|
||||
@code {
|
||||
private List<DataConnection> _connections = new();
|
||||
private List<Site> _sites = new();
|
||||
private Dictionary<int, List<SiteDataConnectionAssignment>> _connectionSites = new();
|
||||
private Dictionary<int, Site> _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<SiteDataConnectionAssignment>();
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -10,7 +10,7 @@ public interface ICentralUiRepository
|
||||
{
|
||||
Task<IReadOnlyList<Site>> GetAllSitesAsync(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<Instance>> GetInstancesFilteredAsync(int? siteId = null, int? templateId = null, string? searchTerm = null, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<DeploymentRecord>> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -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<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)
|
||||
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -46,26 +46,11 @@ public class DataConnectionConfiguration : IEntityTypeConfiguration<DataConnecti
|
||||
builder.Property(d => d.Configuration)
|
||||
.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>()
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.SiteId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
.HasForeignKey(d => d.SiteId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne<DataConnection>()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
1227
src/ScadaLink.ConfigurationDatabase/Migrations/20260321210402_AddSiteIdToDataConnections.Designer.cs
generated
Normal file
1227
src/ScadaLink.ConfigurationDatabase/Migrations/20260321210402_AddSiteIdToDataConnections.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -766,9 +766,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("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<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 =>
|
||||
{
|
||||
b.Property<int>("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();
|
||||
});
|
||||
|
||||
|
||||
@@ -27,17 +27,18 @@ public class CentralUiRepository : ICentralUiRepository
|
||||
|
||||
public async Task<IReadOnlyList<DataConnection>> 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<IReadOnlyList<SiteDataConnectionAssignment>> GetAllSiteDataConnectionAssignmentsAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SiteDataConnectionAssignments
|
||||
return await _context.DataConnections
|
||||
.AsNoTracking()
|
||||
.OrderBy(d => d.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,13 +76,8 @@ public class SiteRepository : ISiteRepository
|
||||
|
||||
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
|
||||
.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<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) ---
|
||||
|
||||
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -35,7 +35,6 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
|
||||
// Sites
|
||||
public DbSet<Site> Sites => Set<Site>();
|
||||
public DbSet<DataConnection> DataConnections => Set<DataConnection>();
|
||||
public DbSet<SiteDataConnectionAssignment> SiteDataConnectionAssignments => Set<SiteDataConnectionAssignment>();
|
||||
|
||||
// Deployment
|
||||
public DbSet<DeploymentRecord> DeploymentRecords => Set<DeploymentRecord>();
|
||||
|
||||
@@ -55,16 +55,18 @@ public class ArtifactDeploymentService
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public async Task<DeployArtifactsCommand> 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
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public async Task<Result<ArtifactDeploymentSummary>> DeployToAllSitesAsync(
|
||||
DeployArtifactsCommand command,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -131,9 +133,17 @@ public class ArtifactDeploymentService
|
||||
if (sites.Count == 0)
|
||||
return Result<ArtifactDeploymentSummary>.Failure("No sites configured.");
|
||||
|
||||
var deploymentId = Guid.NewGuid().ToString("N");
|
||||
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 =>
|
||||
{
|
||||
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.
|
||||
/// </summary>
|
||||
public async Task<Result<SiteArtifactResult>> 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<SiteArtifactResult>.Success(result)
|
||||
@@ -228,7 +241,7 @@ public class ArtifactDeploymentService
|
||||
}
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<object?> HandleListDataConnections(IServiceProvider sp)
|
||||
private static async Task<object?> HandleListDataConnections(IServiceProvider sp, ListDataConnectionsCommand cmd)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||||
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<object?> HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user)
|
||||
{
|
||||
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.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<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
|
||||
@@ -1011,8 +987,7 @@ public class ManagementActor : ReceiveActor
|
||||
private static async Task<object?> HandleDeployArtifacts(IServiceProvider sp, MgmtDeployArtifactsCommand cmd, string user)
|
||||
{
|
||||
var svc = sp.GetRequiredService<ArtifactDeploymentService>();
|
||||
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);
|
||||
|
||||
@@ -8,9 +8,7 @@ namespace ScadaLink.TemplateEngine.Services;
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public class SiteService
|
||||
@@ -98,7 +96,7 @@ public class SiteService
|
||||
// --- Data Connection CRUD ---
|
||||
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
@@ -106,7 +104,7 @@ public class SiteService
|
||||
if (string.IsNullOrWhiteSpace(protocol))
|
||||
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.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -151,56 +149,4 @@ public class SiteService
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -37,9 +37,8 @@ public class ArtifactDeploymentServiceTests
|
||||
_siteRepo.GetAllSitesAsync().Returns(new List<Site>());
|
||||
|
||||
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);
|
||||
|
||||
@@ -200,7 +200,7 @@ public class FlatteningServiceTests
|
||||
|
||||
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(
|
||||
|
||||
@@ -93,44 +93,12 @@ public class SiteServiceTests
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.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<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);
|
||||
Assert.Equal(1, result.Value.SiteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user