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(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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
.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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user