Complete OPC UA data flow: binding UI, flattening connections, real OPC UA client
- Add connection binding UI to Instances page (per-attribute and bulk assign) - FlatteningService populates Connections dict from bound data connections - Real OPC UA client using OPC Foundation SDK for live tag subscriptions - DataConnectionFactory uses RealOpcUaClientFactory by default - OpcUaDataConnection supports both "endpoint" and "EndpointUrl" config keys
This commit is contained in:
@@ -178,10 +178,67 @@
|
|||||||
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
|
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
|
||||||
@onclick="() => EnableInstance(inst)" disabled="@_actionInProgress">Enable</button>
|
@onclick="() => EnableInstance(inst)" disabled="@_actionInProgress">Enable</button>
|
||||||
}
|
}
|
||||||
|
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1"
|
||||||
|
@onclick="() => ToggleBindings(inst)">Bindings</button>
|
||||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||||
@onclick="() => DeleteInstance(inst)" disabled="@_actionInProgress">Delete</button>
|
@onclick="() => DeleteInstance(inst)" disabled="@_actionInProgress">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@if (_bindingInstanceId == inst.Id)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="bg-light p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<strong>Connection Bindings for @inst.UniqueName</strong>
|
||||||
|
@if (_bindingDataSourceAttrs.Count > 0 && _siteConnections.Count > 0)
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<select class="form-select form-select-sm d-inline-block me-1" style="width:auto;" @bind="_bulkConnectionId">
|
||||||
|
<option value="0">Select connection...</option>
|
||||||
|
@foreach (var c in _siteConnections)
|
||||||
|
{
|
||||||
|
<option value="@c.Id">@c.Name (@c.Protocol)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="ApplyBulkBinding" disabled="@(_bulkConnectionId == 0)">Assign All</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (_bindingDataSourceAttrs.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">No data-sourced attributes in this template.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm table-bordered mb-2">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Attribute</th><th>Tag Path</th><th>Connection</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var attr in _bindingDataSourceAttrs)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="small">@attr.Name</td>
|
||||||
|
<td class="small text-muted font-monospace">@attr.DataSourceReference</td>
|
||||||
|
<td>
|
||||||
|
<select class="form-select form-select-sm" value="@GetBindingConnectionId(attr.Name)"
|
||||||
|
@onchange="(e) => OnBindingChanged(attr.Name, e)">
|
||||||
|
<option value="0">— none —</option>
|
||||||
|
@foreach (var c in _siteConnections)
|
||||||
|
{
|
||||||
|
<option value="@c.Id">@c.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button class="btn btn-success btn-sm" @onclick="SaveBindings" disabled="@_actionInProgress">Save Bindings</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -482,4 +539,100 @@
|
|||||||
_createError = $"Create failed: {ex.Message}";
|
_createError = $"Create failed: {ex.Message}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection binding state
|
||||||
|
private int _bindingInstanceId;
|
||||||
|
private List<TemplateAttribute> _bindingDataSourceAttrs = new();
|
||||||
|
private List<DataConnection> _siteConnections = new();
|
||||||
|
private Dictionary<string, int> _bindingSelections = new();
|
||||||
|
private int _bulkConnectionId;
|
||||||
|
|
||||||
|
private async Task ToggleBindings(Instance inst)
|
||||||
|
{
|
||||||
|
if (_bindingInstanceId == inst.Id)
|
||||||
|
{
|
||||||
|
_bindingInstanceId = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindingInstanceId = inst.Id;
|
||||||
|
_bindingSelections.Clear();
|
||||||
|
_bulkConnectionId = 0;
|
||||||
|
|
||||||
|
// Load template attributes with DataSourceReference
|
||||||
|
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId);
|
||||||
|
_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).ToList();
|
||||||
|
|
||||||
|
// Load data connections for this 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);
|
||||||
|
foreach (var b in existingBindings)
|
||||||
|
{
|
||||||
|
_bindingSelections[b.AttributeName] = b.DataConnectionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetBindingConnectionId(string attrName)
|
||||||
|
{
|
||||||
|
return _bindingSelections.GetValueOrDefault(attrName, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBindingChanged(string attrName, ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
var val = int.TryParse(e.Value?.ToString(), out var id) ? id : 0;
|
||||||
|
SetBinding(attrName, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetBinding(string attrName, int connectionId)
|
||||||
|
{
|
||||||
|
if (connectionId == 0)
|
||||||
|
_bindingSelections.Remove(attrName);
|
||||||
|
else
|
||||||
|
_bindingSelections[attrName] = connectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyBulkBinding()
|
||||||
|
{
|
||||||
|
if (_bulkConnectionId == 0) return;
|
||||||
|
foreach (var attr in _bindingDataSourceAttrs)
|
||||||
|
{
|
||||||
|
_bindingSelections[attr.Name] = _bulkConnectionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveBindings()
|
||||||
|
{
|
||||||
|
_actionInProgress = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bindings = _bindingSelections
|
||||||
|
.Select(kv => (kv.Key, kv.Value))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var result = await InstanceService.SetConnectionBindingsAsync(
|
||||||
|
_bindingInstanceId, bindings, "system");
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_toast.ShowSuccess($"Saved {bindings.Count} connection bindings.");
|
||||||
|
_bindingInstanceId = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Save failed: {result.Error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Save failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
_actionInProgress = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ public class OpcUaDataConnection : IDataConnection
|
|||||||
|
|
||||||
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_endpointUrl = connectionDetails.TryGetValue("EndpointUrl", out var url) ? url : "opc.tcp://localhost:4840";
|
// Support both "endpoint" (from JSON config) and "EndpointUrl" (programmatic)
|
||||||
|
_endpointUrl = connectionDetails.TryGetValue("endpoint", out var url)
|
||||||
|
? url
|
||||||
|
: connectionDetails.TryGetValue("EndpointUrl", out var url2)
|
||||||
|
? url2
|
||||||
|
: "opc.tcp://localhost:4840";
|
||||||
_status = ConnectionHealth.Connecting;
|
_status = ConnectionHealth.Connecting;
|
||||||
|
|
||||||
_client = _clientFactory.Create();
|
_client = _clientFactory.Create();
|
||||||
|
|||||||
185
src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs
Normal file
185
src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
using Opc.Ua.Configuration;
|
||||||
|
|
||||||
|
namespace ScadaLink.DataConnectionLayer.Adapters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Real OPC UA client implementation using the OPC Foundation .NET Standard Library.
|
||||||
|
/// Wraps Session, Subscription, and MonitoredItem for tag subscriptions.
|
||||||
|
/// </summary>
|
||||||
|
public class RealOpcUaClient : IOpcUaClient
|
||||||
|
{
|
||||||
|
private ISession? _session;
|
||||||
|
private Subscription? _subscription;
|
||||||
|
private readonly Dictionary<string, MonitoredItem> _monitoredItems = new();
|
||||||
|
private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _callbacks = new();
|
||||||
|
|
||||||
|
public bool IsConnected => _session?.Connected ?? false;
|
||||||
|
|
||||||
|
public async Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var appConfig = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationName = "ScadaLink-DCL",
|
||||||
|
ApplicationType = ApplicationType.Client,
|
||||||
|
SecurityConfiguration = new SecurityConfiguration
|
||||||
|
{
|
||||||
|
AutoAcceptUntrustedCertificates = true,
|
||||||
|
ApplicationCertificate = new CertificateIdentifier()
|
||||||
|
},
|
||||||
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||||
|
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }
|
||||||
|
};
|
||||||
|
|
||||||
|
await appConfig.ValidateAsync(ApplicationType.Client);
|
||||||
|
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||||
|
|
||||||
|
var endpoint = new EndpointDescription(endpointUrl)
|
||||||
|
{
|
||||||
|
SecurityMode = MessageSecurityMode.None,
|
||||||
|
SecurityPolicyUri = SecurityPolicies.None
|
||||||
|
};
|
||||||
|
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||||
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
|
||||||
|
|
||||||
|
#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
|
||||||
|
var sessionFactory = new DefaultSessionFactory();
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
_session = await sessionFactory.CreateAsync(
|
||||||
|
appConfig, configuredEndpoint, false,
|
||||||
|
"ScadaLink-DCL-Session", 60000, null, null, cancellationToken);
|
||||||
|
|
||||||
|
// Create a default subscription for all monitored items
|
||||||
|
_subscription = new Subscription(_session.DefaultSubscription)
|
||||||
|
{
|
||||||
|
DisplayName = "ScadaLink",
|
||||||
|
PublishingEnabled = true,
|
||||||
|
PublishingInterval = 1000,
|
||||||
|
KeepAliveCount = 10,
|
||||||
|
LifetimeCount = 30,
|
||||||
|
MaxNotificationsPerPublish = 100
|
||||||
|
};
|
||||||
|
|
||||||
|
_session.AddSubscription(_subscription);
|
||||||
|
await _subscription.CreateAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_subscription != null)
|
||||||
|
{
|
||||||
|
await _subscription.DeleteAsync(true);
|
||||||
|
_subscription = null;
|
||||||
|
}
|
||||||
|
if (_session != null)
|
||||||
|
{
|
||||||
|
await _session.CloseAsync(cancellationToken);
|
||||||
|
_session = null;
|
||||||
|
}
|
||||||
|
_monitoredItems.Clear();
|
||||||
|
_callbacks.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> CreateSubscriptionAsync(
|
||||||
|
string nodeId, Action<string, object?, DateTime, uint> onValueChanged,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_subscription == null || _session == null)
|
||||||
|
throw new InvalidOperationException("Not connected.");
|
||||||
|
|
||||||
|
var handle = Guid.NewGuid().ToString();
|
||||||
|
var monitoredItem = new MonitoredItem(_subscription.DefaultItem)
|
||||||
|
{
|
||||||
|
DisplayName = nodeId,
|
||||||
|
StartNodeId = nodeId,
|
||||||
|
AttributeId = Attributes.Value,
|
||||||
|
SamplingInterval = 1000,
|
||||||
|
QueueSize = 10,
|
||||||
|
DiscardOldest = true
|
||||||
|
};
|
||||||
|
|
||||||
|
_callbacks[handle] = onValueChanged;
|
||||||
|
|
||||||
|
monitoredItem.Notification += (item, e) =>
|
||||||
|
{
|
||||||
|
if (e.NotificationValue is MonitoredItemNotification notification)
|
||||||
|
{
|
||||||
|
var value = notification.Value?.Value;
|
||||||
|
var timestamp = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
|
||||||
|
var statusCode = notification.Value?.StatusCode.Code ?? 0;
|
||||||
|
|
||||||
|
if (_callbacks.TryGetValue(handle, out var cb))
|
||||||
|
{
|
||||||
|
cb(nodeId, value, timestamp, statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_subscription.AddItem(monitoredItem);
|
||||||
|
await _subscription.ApplyChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_monitoredItems[handle] = monitoredItem;
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_subscription != null && _monitoredItems.TryGetValue(subscriptionHandle, out var item))
|
||||||
|
{
|
||||||
|
_subscription.RemoveItem(item);
|
||||||
|
await _subscription.ApplyChangesAsync(cancellationToken);
|
||||||
|
_monitoredItems.Remove(subscriptionHandle);
|
||||||
|
_callbacks.Remove(subscriptionHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
|
||||||
|
string nodeId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_session == null) throw new InvalidOperationException("Not connected.");
|
||||||
|
|
||||||
|
var readValue = new ReadValueId
|
||||||
|
{
|
||||||
|
NodeId = nodeId,
|
||||||
|
AttributeId = Attributes.Value
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await _session.ReadAsync(
|
||||||
|
null, 0, TimestampsToReturn.Source,
|
||||||
|
new ReadValueIdCollection { readValue }, cancellationToken);
|
||||||
|
|
||||||
|
var result = response.Results[0];
|
||||||
|
return (result.Value, result.SourceTimestamp, result.StatusCode.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_session == null) throw new InvalidOperationException("Not connected.");
|
||||||
|
|
||||||
|
var writeValue = new WriteValue
|
||||||
|
{
|
||||||
|
NodeId = nodeId,
|
||||||
|
AttributeId = Attributes.Value,
|
||||||
|
Value = new DataValue(new Variant(value))
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await _session.WriteAsync(
|
||||||
|
null, new WriteValueCollection { writeValue }, cancellationToken);
|
||||||
|
|
||||||
|
return response.Results[0].Code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await DisconnectAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory that creates real OPC UA client instances using the OPC Foundation SDK.
|
||||||
|
/// </summary>
|
||||||
|
public class RealOpcUaClientFactory : IOpcUaClientFactory
|
||||||
|
{
|
||||||
|
public IOpcUaClient Create() => new RealOpcUaClient();
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ public class DataConnectionFactory : IDataConnectionFactory
|
|||||||
|
|
||||||
// Register built-in protocols
|
// Register built-in protocols
|
||||||
RegisterAdapter("OpcUa", details => new OpcUaDataConnection(
|
RegisterAdapter("OpcUa", details => new OpcUaDataConnection(
|
||||||
new DefaultOpcUaClientFactory(), _loggerFactory.CreateLogger<OpcUaDataConnection>()));
|
new RealOpcUaClientFactory(), _loggerFactory.CreateLogger<OpcUaDataConnection>()));
|
||||||
RegisterAdapter("LmxProxy", details => new LmxProxyDataConnection(
|
RegisterAdapter("LmxProxy", details => new LmxProxyDataConnection(
|
||||||
new DefaultLmxProxyClientFactory(), _loggerFactory.CreateLogger<LmxProxyDataConnection>()));
|
new DefaultLmxProxyClientFactory(), _loggerFactory.CreateLogger<LmxProxyDataConnection>()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
|
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.5" />
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.5" />
|
||||||
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -77,6 +77,25 @@ public class FlatteningService
|
|||||||
// Step 7: Resolve alarm on-trigger script references to canonical names
|
// Step 7: Resolve alarm on-trigger script references to canonical names
|
||||||
ResolveAlarmScriptReferences(alarms, scripts);
|
ResolveAlarmScriptReferences(alarms, scripts);
|
||||||
|
|
||||||
|
// Step 8: Collect connection configurations for deployment packaging
|
||||||
|
var connections = new Dictionary<string, ConnectionConfig>();
|
||||||
|
foreach (var attr in attributes.Values)
|
||||||
|
{
|
||||||
|
if (attr.BoundDataConnectionId.HasValue &&
|
||||||
|
!string.IsNullOrEmpty(attr.BoundDataConnectionName) &&
|
||||||
|
!connections.ContainsKey(attr.BoundDataConnectionName))
|
||||||
|
{
|
||||||
|
if (dataConnections.TryGetValue(attr.BoundDataConnectionId.Value, out var conn))
|
||||||
|
{
|
||||||
|
connections[attr.BoundDataConnectionName] = new ConnectionConfig
|
||||||
|
{
|
||||||
|
Protocol = conn.Protocol,
|
||||||
|
ConfigurationJson = conn.Configuration
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var config = new FlattenedConfiguration
|
var config = new FlattenedConfiguration
|
||||||
{
|
{
|
||||||
InstanceUniqueName = instance.UniqueName,
|
InstanceUniqueName = instance.UniqueName,
|
||||||
@@ -86,6 +105,7 @@ public class FlatteningService
|
|||||||
Attributes = attributes.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
Attributes = attributes.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||||
Alarms = alarms.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
Alarms = alarms.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||||
Scripts = scripts.Values.OrderBy(s => s.CanonicalName, StringComparer.Ordinal).ToList(),
|
Scripts = scripts.Values.OrderBy(s => s.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||||
|
Connections = connections.Count > 0 ? connections : null,
|
||||||
GeneratedAtUtc = DateTimeOffset.UtcNow
|
GeneratedAtUtc = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user