From 8462a640e4387c96db0c8e340e1813836bba3619 Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Snak Date: Tue, 2 Jun 2026 10:59:15 -0300 Subject: [PATCH 01/12] Adds OWIN-hosted Web API with Swagger UI and GET /api/connections endpoint --- .../Api/Controllers/ConnectionsController.cs | 98 +++++++++++++++ .../Api/Models/ConnectionDto.cs | 116 ++++++++++++++++++ .../StreamSplitter/Api/Startup.cs | 76 ++++++++++++ .../StreamSplitter/ServiceHost.cs | 64 ++++++++++ .../StreamSplitter/StreamSplitter.csproj | 62 ++++++++++ Source/Applications/StreamSplitter/app.config | 77 ++++++++---- .../StreamSplitter/packages.config | 16 +++ 7 files changed, 484 insertions(+), 25 deletions(-) create mode 100644 Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs create mode 100644 Source/Applications/StreamSplitter/Api/Models/ConnectionDto.cs create mode 100644 Source/Applications/StreamSplitter/Api/Startup.cs create mode 100644 Source/Applications/StreamSplitter/packages.config diff --git a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs new file mode 100644 index 0000000..c4bdfb4 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs @@ -0,0 +1,98 @@ +//****************************************************************************************************** +// ConnectionsController.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/01/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// +//****************************************************************************************************** + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web.Http; +using GSF.Diagnostics; +using StreamSplitter.Api.Models; + +namespace StreamSplitter.Api.Controllers +{ + /// + /// Provides read access to the currently configured proxy connections. + /// + [RoutePrefix("api/connections")] + public class ConnectionsController : ApiController + { + #region [ Members ] + + // Fields + private static readonly LogPublisher s_log = + Logger.CreatePublisher(typeof(ConnectionsController), MessageClass.Application); + + #endregion + + #region [ Methods ] + + /// + /// Returns all currently configured proxy connections. + /// + /// Array of objects representing all configured connections. + [HttpGet, Route("")] + public IHttpActionResult GetConnections() + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "GetConnections", + $"GET /api/connections requested. CorrelationId={correlationId}"); + + ProxyConnectionCollection configuration = ServiceHost.Current?.CurrentConfiguration; + + if (configuration is null) + { + s_log.Publish( + MessageLevel.Warning, + "GetConnections", + $"Configuration not yet loaded. Returning empty list. CorrelationId={correlationId}"); + + return Ok(Array.Empty()); + } + + ConnectionDto[] dtos = configuration + .Select(ConnectionDto.FromProxyConnection) + .ToArray(); + + s_log.Publish( + MessageLevel.Info, + "GetConnections", + $"Returning {dtos.Length} connection(s). CorrelationId={correlationId}"); + + return Ok(dtos); + } + + // Extracts the X-Correlation-Id header value, or generates a new GUID string if absent. + private string GetCorrelationId() + { + if (Request.Headers.TryGetValues("X-Correlation-Id", out IEnumerable values)) + return values.FirstOrDefault() ?? Guid.NewGuid().ToString(); + + return Guid.NewGuid().ToString(); + } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/Api/Models/ConnectionDto.cs b/Source/Applications/StreamSplitter/Api/Models/ConnectionDto.cs new file mode 100644 index 0000000..254fbb0 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Models/ConnectionDto.cs @@ -0,0 +1,116 @@ +//****************************************************************************************************** +// ConnectionDto.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/01/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// +//****************************************************************************************************** + +using System; +using StreamSplitter; + +namespace StreamSplitter.Api.Models +{ + /// + /// Read-only data transfer object representing a . + /// + public sealed class ConnectionDto + { + #region [ Constructors ] + + private ConnectionDto() + { + } + + #endregion + + #region [ Properties ] + + /// + /// Gets the unique identifier of the connection. + /// + public Guid Id { get; private set; } + + /// + /// Gets the display name of the connection. + /// + public string Name { get; private set; } + + /// + /// Gets a value indicating whether the connection is enabled. + /// + public bool Enabled { get; private set; } + + /// + /// Gets the current of the connection. + /// + public ConnectionState ConnectionState { get; private set; } + + /// + /// Gets a human-readable description of the current connection state. + /// + public string ConnectionStateDescription { get; private set; } + + /// + /// Gets the raw GSF connection string. + /// + public string ConnectionString { get; private set; } + + /// + /// Gets the source-side settings extracted from the connection string. + /// + public string SourceSettings { get; private set; } + + /// + /// Gets the proxy-side settings extracted from the connection string. + /// + public string ProxySettings { get; private set; } + + #endregion + + #region [ Static ] + + // Static Methods + + /// + /// Creates a new from a . + /// + /// Source to map from. + /// A populated . + /// is null. + public static ConnectionDto FromProxyConnection(ProxyConnection connection) + { + if (connection is null) + throw new ArgumentNullException(nameof(connection)); + + return new ConnectionDto + { + Id = connection.ID, + Name = connection.Name, + Enabled = connection.Enabled, + ConnectionState = connection.ConnectionState, + ConnectionStateDescription = connection.ConnectionStateDescription, + ConnectionString = connection.ConnectionString, + SourceSettings = connection.SourceSettings, + ProxySettings = connection.ProxySettings + }; + } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/Api/Startup.cs b/Source/Applications/StreamSplitter/Api/Startup.cs new file mode 100644 index 0000000..9e6eb60 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Startup.cs @@ -0,0 +1,76 @@ +//****************************************************************************************************** +// Startup.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/01/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// +//****************************************************************************************************** + +using System.Web.Http; +using Newtonsoft.Json.Converters; +using Owin; +using Swashbuckle.Application; + +namespace StreamSplitter.Api +{ + /// + /// OWIN startup class for the Stream Splitter Web API. + /// Configures attribute routing, JSON serialization, Swagger UI, and the Web API middleware. + /// + /// + /// This class is passed directly to WebApp.Start<Startup>() in + /// — the [assembly: OwinStartup] + /// auto-discovery attribute is intentionally omitted. + /// + public class Startup + { + #region [ Methods ] + + /// + /// Configures the OWIN middleware pipeline. + /// + /// The instance to configure. + public void Configuration(IAppBuilder app) + { + HttpConfiguration config = new HttpConfiguration(); + + // Enable attribute-based routing ([RoutePrefix] / [Route] on controllers) + config.MapHttpAttributeRoutes(); + + // Serialize enum values as strings for readability in JSON responses + config.Formatters.JsonFormatter.SerializerSettings.Converters + .Add(new StringEnumConverter()); + + // Remove XML formatter — this API speaks JSON only + config.Formatters.Remove(config.Formatters.XmlFormatter); + + // Configure Swashbuckle: single API version, enums as strings in schema + config + .EnableSwagger(c => + { + c.SingleApiVersion("v1", "Stream Splitter API"); + c.DescribeAllEnumsAsStrings(); + }) + .EnableSwaggerUi(); + + app.UseWebApi(config); + } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/ServiceHost.cs b/Source/Applications/StreamSplitter/ServiceHost.cs index 7a9ac20..1c2a1b3 100755 --- a/Source/Applications/StreamSplitter/ServiceHost.cs +++ b/Source/Applications/StreamSplitter/ServiceHost.cs @@ -18,6 +18,9 @@ // ---------------------------------------------------------------------------------------------------- // 09/04/2013 - J. Ritchie Carroll // Generated original version of source code. +// 06/01/2026 - Marcos Vinicius Snak +// Added OWIN-based Web API hosting (Story 1): TryStartWebHosting, CurrentConfiguration, +// ServiceHost.Current static accessor. // //****************************************************************************************************** @@ -43,7 +46,9 @@ using GSF.PhasorProtocols; using GSF.ServiceProcess; using GSF.Units; +using Microsoft.Owin.Hosting; using Microsoft.Win32; +using StreamSplitter.Api; namespace StreamSplitter { @@ -62,6 +67,8 @@ public sealed partial class ServiceHost : ServiceBase private const int DefaultMinThreadPoolIOPortSize = (int)(DefaultMinThreadPoolWorkerSize + DefaultMinThreadPoolWorkerSize * 0.2D); private const int DefaultMaxThreadPoolIOPortSize = (int)(DefaultMaxThreadPoolWorkerSize + DefaultMaxThreadPoolWorkerSize * 0.2D); private const int DefaultMaxLogFiles = 300; + private const bool DefaultWebHostingEnabled = true; + private const string DefaultWebHostURL = "http://localhost:8283"; // Fields private AutoResetEvent m_configurationLoadComplete; @@ -69,6 +76,7 @@ public sealed partial class ServiceHost : ServiceBase private volatile ProxyConnectionCollection m_currentConfiguration; private readonly List m_streamSplitters; private readonly ConcurrentDictionary m_derivedNameCache; + private IDisposable m_webAppHost; #endregion @@ -94,6 +102,8 @@ public ServiceHost() // Create a cache for derived proxy connection names m_derivedNameCache = new ConcurrentDictionary(); + + Current = this; } public ServiceHost(IContainer container) @@ -121,6 +131,17 @@ public ServiceHost(IContainer container) /// private TcpServer RemotingServer => m_remotingServer; + /// + /// Gets the current proxy connection configuration. + /// Returns null if the configuration has not yet been loaded. + /// + internal ProxyConnectionCollection CurrentConfiguration => m_currentConfiguration; + + /// + /// Gets the singleton instance, available after the service starts. + /// + internal static ServiceHost Current { get; private set; } + #endregion #region [ Methods ] @@ -145,6 +166,8 @@ private void ServiceHelper_ServiceStarting(object sender, EventArgs e) systemSettings.Add("LogPath", defaultLogPath, "Defines the path used to archive log files"); systemSettings.Add("MaxLogFiles", DefaultMaxLogFiles, "Defines the maximum number of log files to keep"); systemSettings.Add("DefaultCulture", "en-US", "Default culture to use for language, country/region and calendar formats."); + systemSettings.Add("WebHostingEnabled", DefaultWebHostingEnabled, "Flag that determines if the web API hosting is enabled."); + systemSettings.Add("WebHostURL", DefaultWebHostURL, "URL endpoint where the Stream Splitter web API is hosted."); // Create a handler for unobserved task exceptions TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; @@ -276,10 +299,15 @@ private void ServiceHelper_ServiceStarted(object sender, EventArgs e) m_serviceHelper.ClientRequestHandlers.Add(new ClientRequestHandler("SendCommand", "Sends command to a specific stream splitter", SendCommandHandler)); LoadCurrentConfiguration(); + TryStartWebHosting(); } private void ServiceHelper_ServiceStopping(object sender, EventArgs e) { + // Stop web API host before stream splitters so no new requests arrive during shutdown + m_webAppHost?.Dispose(); + m_webAppHost = null; + lock (m_streamSplitters) { foreach (StreamProxy splitter in m_streamSplitters) @@ -317,6 +345,42 @@ private void ServiceHelper_ServiceStopping(object sender, EventArgs e) // Unattach from handler for unobserved task exceptions TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException; + + Current = null; + } + + // Starts the OWIN-based web API host using settings from the configuration file. + private void TryStartWebHosting() + { + try + { + CategorizedSettingsElementCollection systemSettings = + ConfigurationFile.Current.Settings["systemSettings"]; + + if (!systemSettings["WebHostingEnabled"].ValueAs(DefaultWebHostingEnabled)) + { + DisplayStatusMessage("Web API hosting is disabled per configuration.", UpdateType.Information); + return; + } + + string webHostURL = systemSettings["WebHostURL"].ValueAs(DefaultWebHostURL); + + m_webAppHost = WebApp.Start(webHostURL); + + DisplayStatusMessage( + "Web API hosting started at \"{0}\". Swagger UI: {0}/swagger", + UpdateType.Information, + webHostURL); + } + catch (Exception ex) + { + DisplayStatusMessage( + "Failed to start web API hosting due to exception: {0}", + UpdateType.Alarm, + ex.Message); + + Logger.SwallowException(ex); + } } // Handle task scheduler exceptions diff --git a/Source/Applications/StreamSplitter/StreamSplitter.csproj b/Source/Applications/StreamSplitter/StreamSplitter.csproj index fa3728c..56da099 100755 --- a/Source/Applications/StreamSplitter/StreamSplitter.csproj +++ b/Source/Applications/StreamSplitter/StreamSplitter.csproj @@ -82,10 +82,65 @@ False ..\..\Dependencies\GSF\GSF.TimeSeries.dll + + ..\..\Dependencies\GSF\Microsoft.Owin.dll + + + ..\..\Dependencies\GSF\Microsoft.Owin.Host.HttpListener.dll + + + ..\..\Dependencies\GSF\Microsoft.Owin.Hosting.dll + + + + ..\..\packages\Newtonsoft.Json.13.0.4\lib\net45\Newtonsoft.Json.dll + + + ..\..\packages\Newtonsoft.Json.Bson.1.0.2\lib\net45\Newtonsoft.Json.Bson.dll + + + ..\..\Dependencies\GSF\Owin.dll + + + ..\..\packages\Swashbuckle.Core.5.6.0\lib\net40\Swashbuckle.Core.dll + + + ..\..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll + + + ..\..\packages\System.Memory.4.5.5\lib\net461\System.Memory.dll + + + + ..\..\packages\Microsoft.Net.Http.2.2.29\lib\net45\System.Net.Http.Extensions.dll + + + ..\..\packages\Microsoft.AspNet.WebApi.Client.6.0.0\lib\net45\System.Net.Http.Formatting.dll + + + ..\..\packages\Microsoft.Net.Http.2.2.29\lib\net45\System.Net.Http.Primitives.dll + + + + + ..\..\packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll + + + ..\..\Dependencies\GSF\System.Runtime.CompilerServices.Unsafe.dll + + + ..\..\packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll + + + ..\..\packages\Microsoft.AspNet.WebApi.Core.5.3.0\lib\net45\System.Web.Http.dll + + + ..\..\Dependencies\GSF\System.Web.Http.Owin.dll + @@ -113,8 +168,15 @@ ServiceHost.cs + + + + + + + diff --git a/Source/Applications/StreamSplitter/app.config b/Source/Applications/StreamSplitter/app.config index 4a9bee3..2dcb81a 100755 --- a/Source/Applications/StreamSplitter/app.config +++ b/Source/Applications/StreamSplitter/app.config @@ -1,39 +1,66 @@ - + -
+
- + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - + + + + + + + + - + - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Applications/StreamSplitter/packages.config b/Source/Applications/StreamSplitter/packages.config new file mode 100644 index 0000000..c2a6fca --- /dev/null +++ b/Source/Applications/StreamSplitter/packages.config @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file From b00fa1e50113810c84d1c867a6cc124fbba1f6cc Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Snak Date: Tue, 2 Jun 2026 16:33:09 -0300 Subject: [PATCH 02/12] Fixes ConnectionState always returning Disabled in GET /api/connections --- .../Api/Controllers/ConnectionsController.cs | 2 +- .../StreamSplitter/Api/Models/ConnectionDto.cs | 14 ++++++++++---- Source/Applications/StreamSplitter/ServiceHost.cs | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs index c4bdfb4..b490cd0 100644 --- a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs +++ b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs @@ -73,7 +73,7 @@ public IHttpActionResult GetConnections() } ConnectionDto[] dtos = configuration - .Select(ConnectionDto.FromProxyConnection) + .Select(c => ConnectionDto.FromProxyConnection(c, ServiceHost.Current.GetRuntimeConnectionState(c.ID))) .ToArray(); s_log.Publish( diff --git a/Source/Applications/StreamSplitter/Api/Models/ConnectionDto.cs b/Source/Applications/StreamSplitter/Api/Models/ConnectionDto.cs index 254fbb0..478ce65 100644 --- a/Source/Applications/StreamSplitter/Api/Models/ConnectionDto.cs +++ b/Source/Applications/StreamSplitter/Api/Models/ConnectionDto.cs @@ -22,6 +22,7 @@ //****************************************************************************************************** using System; +using GSF; using StreamSplitter; namespace StreamSplitter.Api.Models @@ -88,12 +89,17 @@ private ConnectionDto() // Static Methods /// - /// Creates a new from a . + /// Creates a new from a and its + /// runtime . /// /// Source to map from. + /// + /// Live read from the associated . + /// Pass when no live proxy exists. + /// /// A populated . /// is null. - public static ConnectionDto FromProxyConnection(ProxyConnection connection) + public static ConnectionDto FromProxyConnection(ProxyConnection connection, ConnectionState runtimeState) { if (connection is null) throw new ArgumentNullException(nameof(connection)); @@ -103,8 +109,8 @@ public static ConnectionDto FromProxyConnection(ProxyConnection connection) Id = connection.ID, Name = connection.Name, Enabled = connection.Enabled, - ConnectionState = connection.ConnectionState, - ConnectionStateDescription = connection.ConnectionStateDescription, + ConnectionState = runtimeState, + ConnectionStateDescription = runtimeState.GetDescription(), ConnectionString = connection.ConnectionString, SourceSettings = connection.SourceSettings, ProxySettings = connection.ProxySettings diff --git a/Source/Applications/StreamSplitter/ServiceHost.cs b/Source/Applications/StreamSplitter/ServiceHost.cs index 1c2a1b3..90cd44e 100755 --- a/Source/Applications/StreamSplitter/ServiceHost.cs +++ b/Source/Applications/StreamSplitter/ServiceHost.cs @@ -137,6 +137,21 @@ public ServiceHost(IContainer container) /// internal ProxyConnectionCollection CurrentConfiguration => m_currentConfiguration; + /// + /// Gets the runtime for the identified by + /// . Returns when no live + /// proxy exists for the given ID (connection is disabled or not yet materialized). + /// + /// ID of the to look up. + internal ConnectionState GetRuntimeConnectionState(Guid connectionId) + { + lock (m_streamSplitters) + { + StreamProxy splitter = m_streamSplitters.Find(s => s.ID == connectionId); + return splitter?.StreamProxyStatus.ConnectionState ?? ConnectionState.Disabled; + } + } + /// /// Gets the singleton instance, available after the service starts. /// From a638b7961ad103a9992307c0b05f4bc8189adc92 Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Snak Date: Tue, 2 Jun 2026 16:48:58 -0300 Subject: [PATCH 03/12] Adds GET /api/connections/{id} endpoint with not found (404) handling --- .../Api/Controllers/ConnectionsController.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs index b490cd0..571b3e0 100644 --- a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs +++ b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs @@ -24,6 +24,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Web.Http; using GSF.Diagnostics; using StreamSplitter.Api.Models; @@ -84,6 +85,53 @@ public IHttpActionResult GetConnections() return Ok(dtos); } + /// + /// Returns the proxy connection identified by . + /// + /// Unique identifier of the connection. + /// + /// when found; 404 with a problem detail body when not found. + /// + [HttpGet, Route("{id:guid}")] + public IHttpActionResult GetConnectionById(Guid id) + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "GetConnectionById", + $"GET /api/connections/{id} requested. CorrelationId={correlationId}"); + + ProxyConnectionCollection configuration = ServiceHost.Current?.CurrentConfiguration; + ProxyConnection connection = configuration?[id]; + + if (connection is null) + { + s_log.Publish( + MessageLevel.Info, + "GetConnectionById", + $"Connection {id} not found. CorrelationId={correlationId}"); + + return Content(HttpStatusCode.NotFound, new + { + status = 404, + title = "Not Found", + detail = $"Connection with ID '{id}' was not found." + }); + } + + ConnectionDto dto = ConnectionDto.FromProxyConnection( + connection, + ServiceHost.Current.GetRuntimeConnectionState(id)); + + s_log.Publish( + MessageLevel.Info, + "GetConnectionById", + $"Returning connection '{connection.Name}'. CorrelationId={correlationId}"); + + return Ok(dto); + } + // Extracts the X-Correlation-Id header value, or generates a new GUID string if absent. private string GetCorrelationId() { From 4ba005e98225807d42c56633533d9e0806f24b1e Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Snak Date: Wed, 3 Jun 2026 10:03:46 -0300 Subject: [PATCH 04/12] Adds Web API dependency components to StreamSplitterSetup installer --- .../StreamSplitterSetup.wxs | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wxs b/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wxs index bfe2d17..56e5de9 100755 --- a/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wxs +++ b/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wxs @@ -29,6 +29,7 @@ + @@ -231,6 +232,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 402a10d748363c27321c752de290fa461a04990a Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Snak Date: Fri, 5 Jun 2026 18:02:37 -0300 Subject: [PATCH 05/12] Adds POST /api/connections endpoint and fixes Manager auto-refresh on API changes --- .../Api/Controllers/ConnectionsController.cs | 201 ++++++++++++++++++ .../Api/Filters/FileUploadOperationFilter.cs | 65 ++++++ .../Api/Models/CreateConnectionRequest.cs | 44 ++++ .../StreamSplitter/Api/Startup.cs | 4 + .../StreamSplitter/ServiceHost.cs | 41 ++++ .../StreamSplitter/StreamSplitter.csproj | 2 + .../StreamSplitterManager.cs | 48 +++-- 7 files changed, 390 insertions(+), 15 deletions(-) create mode 100644 Source/Applications/StreamSplitter/Api/Filters/FileUploadOperationFilter.cs create mode 100644 Source/Applications/StreamSplitter/Api/Models/CreateConnectionRequest.cs diff --git a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs index 571b3e0..d634161 100644 --- a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs +++ b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs @@ -23,8 +23,11 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; +using System.Net.Http; +using System.Threading.Tasks; using System.Web.Http; using GSF.Diagnostics; using StreamSplitter.Api.Models; @@ -132,6 +135,204 @@ public IHttpActionResult GetConnectionById(Guid id) return Ok(dto); } + /// + /// Creates one or more proxy connections from a JSON array. + /// All items are validated before any connection is created (all-or-nothing semantics). + /// + /// + /// Array of connection data. connectionString is required on each item. + /// + /// + /// 201 Created with the array of created objects, + /// or 400 Bad Request if any item fails validation. + /// + [HttpPost, Route("")] + public IHttpActionResult CreateConnections([FromBody] CreateConnectionRequest[] requests) + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "CreateConnections", + $"POST /api/connections requested ({requests?.Length ?? 0} item(s)). CorrelationId={correlationId}"); + + if (requests is null || requests.Length == 0) + { + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "The request body must be a non-empty JSON array of connection objects." + }); + } + + // Validate all items before creating any — all-or-nothing semantics. + for (int i = 0; i < requests.Length; i++) + { + if (string.IsNullOrWhiteSpace(requests[i]?.ConnectionString)) + { + s_log.Publish( + MessageLevel.Warning, + "CreateConnections", + $"Validation failed at index {i}: connectionString is required. CorrelationId={correlationId}"); + + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = $"Item at index {i} is missing a required connectionString." + }); + } + } + + List created = new List(); + + foreach (CreateConnectionRequest request in requests) + { + ProxyConnection connection = new ProxyConnection { ConnectionString = request.ConnectionString }; + + try + { + ServiceHost.Current.AddConnection(connection); + } + catch (InvalidOperationException ex) + { + return Content(HttpStatusCode.ServiceUnavailable, new + { + status = 503, + title = "Service Unavailable", + detail = ex.Message + }); + } + + created.Add(ConnectionDto.FromProxyConnection( + connection, + ServiceHost.Current.GetRuntimeConnectionState(connection.ID))); + + s_log.Publish( + MessageLevel.Info, + "CreateConnections", + $"Connection '{connection.Name}' created. Id={connection.ID}. CorrelationId={correlationId}"); + } + + return Created("/api/connections", created.ToArray()); + } + + /// + /// Imports connections from a .s3config file sent as multipart/form-data. + /// Each connection in the file is assigned a new server-generated ID to ensure + /// CREATE semantics regardless of the IDs in the file. + /// + /// + /// 201 Created with the array of imported objects, + /// or 400 Bad Request if the file is missing, empty, or not a valid .s3config. + /// + [HttpPost, Route("import")] + public async Task ImportFromFile() + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "ImportFromFile", + $"POST /api/connections/import requested. CorrelationId={correlationId}"); + + if (!Request.Content.IsMimeMultipartContent()) + { + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "Expected multipart/form-data with a .s3config file." + }); + } + + MultipartMemoryStreamProvider provider = new MultipartMemoryStreamProvider(); + await Request.Content.ReadAsMultipartAsync(provider); + + HttpContent filePart = provider.Contents.FirstOrDefault(); + + if (filePart is null) + { + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "No file was included in the request." + }); + } + + // Read the part as a stream to avoid a redundant byte[] copy — the + // MultipartMemoryStreamProvider already buffers content in a MemoryStream. + using Stream fileStream = await filePart.ReadAsStreamAsync(); + + if (fileStream.Length == 0) + { + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "The uploaded file is empty." + }); + } + + ProxyConnectionCollection imported; + + try + { + imported = ProxyConnectionCollection.DeserializeConfiguration(fileStream); + } + catch (Exception ex) + { + s_log.Publish( + MessageLevel.Warning, + "ImportFromFile", + $"Failed to deserialize file. Error={ex.Message}. CorrelationId={correlationId}"); + + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "The file could not be read as a valid .s3config configuration." + }); + } + + if (imported.Count == 0) + { + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "The .s3config file contains no connections." + }); + } + + List created = new List(); + + foreach (ProxyConnection source in imported) + { + // Assign a new ID to guarantee CREATE semantics — never UPDATE an existing connection. + ProxyConnection connection = new ProxyConnection + { + ConnectionString = source.ConnectionString, + ConnectionParameters = source.ConnectionParameters + }; + + ServiceHost.Current.AddConnection(connection); + + created.Add(ConnectionDto.FromProxyConnection( + connection, + ServiceHost.Current.GetRuntimeConnectionState(connection.ID))); + } + + s_log.Publish( + MessageLevel.Info, + "ImportFromFile", + $"Imported {created.Count} connection(s). CorrelationId={correlationId}"); + + return Created("/api/connections", created.ToArray()); + } + // Extracts the X-Correlation-Id header value, or generates a new GUID string if absent. private string GetCorrelationId() { diff --git a/Source/Applications/StreamSplitter/Api/Filters/FileUploadOperationFilter.cs b/Source/Applications/StreamSplitter/Api/Filters/FileUploadOperationFilter.cs new file mode 100644 index 0000000..d910cd1 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Filters/FileUploadOperationFilter.cs @@ -0,0 +1,65 @@ +//****************************************************************************************************** +// FileUploadOperationFilter.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/05/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// +//****************************************************************************************************** + +using System.Collections.Generic; +using System.Web.Http.Description; +using Swashbuckle.Swagger; + +namespace StreamSplitter.Api.Filters +{ + /// + /// Swashbuckle operation filter that replaces the body parameter on the + /// ImportFromFile action with a multipart/form-data file picker, + /// enabling file upload directly from the Swagger UI. + /// + public class FileUploadOperationFilter : IOperationFilter + { + #region [ Methods ] + + /// + public void Apply(Operation operation, SchemaRegistry schemaRegistry, ApiDescription apiDescription) + { + bool isImport = + apiDescription.ActionDescriptor.ControllerDescriptor.ControllerName == "Connections" + && apiDescription.ActionDescriptor.ActionName == "ImportFromFile"; + + if (!isImport) + return; + + operation.consumes = new List { "multipart/form-data" }; + operation.parameters = new List + { + new Parameter + { + name = "file", + @in = "formData", + required = true, + type = "file", + description = "Configuration file (.s3config) exported from StreamSplitterManager." + } + }; + } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/Api/Models/CreateConnectionRequest.cs b/Source/Applications/StreamSplitter/Api/Models/CreateConnectionRequest.cs new file mode 100644 index 0000000..abf7295 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Models/CreateConnectionRequest.cs @@ -0,0 +1,44 @@ +//****************************************************************************************************** +// CreateConnectionRequest.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/05/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// +//****************************************************************************************************** + +namespace StreamSplitter.Api.Models +{ + /// + /// Request body for creating a new proxy connection via the REST API. + /// + public class CreateConnectionRequest + { + #region [ Properties ] + + /// + /// Gets or sets the full GSF connection string (key=value pairs). + /// The string must include all required parameters such as name, enabled, + /// sourceSettings and proxySettings embedded in the standard GSF format. + /// This is the only required field; all other connection attributes are + /// parsed from it automatically. + /// + public string ConnectionString { get; set; } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/Api/Startup.cs b/Source/Applications/StreamSplitter/Api/Startup.cs index 9e6eb60..115bcc0 100644 --- a/Source/Applications/StreamSplitter/Api/Startup.cs +++ b/Source/Applications/StreamSplitter/Api/Startup.cs @@ -18,6 +18,8 @@ // ---------------------------------------------------------------------------------------------------- // 06/01/2026 - Marcos Vinicius Snak // Generated original version of source code. +// 06/05/2026 - Marcos Vinicius Snak +// Added FileUploadOperationFilter to enable file picker on /import endpoint in Swagger UI. // //****************************************************************************************************** @@ -25,6 +27,7 @@ using Newtonsoft.Json.Converters; using Owin; using Swashbuckle.Application; +using StreamSplitter.Api.Filters; namespace StreamSplitter.Api { @@ -65,6 +68,7 @@ public void Configuration(IAppBuilder app) { c.SingleApiVersion("v1", "Stream Splitter API"); c.DescribeAllEnumsAsStrings(); + c.OperationFilter(); }) .EnableSwaggerUi(); diff --git a/Source/Applications/StreamSplitter/ServiceHost.cs b/Source/Applications/StreamSplitter/ServiceHost.cs index 90cd44e..b19b0bf 100755 --- a/Source/Applications/StreamSplitter/ServiceHost.cs +++ b/Source/Applications/StreamSplitter/ServiceHost.cs @@ -1277,6 +1277,47 @@ private void HandleException(Exception ex) m_serviceHelper.UpdateStatus(UpdateType.Alarm, ex.Message + newLines); } + /// + /// Adds or updates a in the running configuration, + /// materializes the corresponding , and persists the change to disk. + /// Mirrors the logic used by the TCP-based UploadConnection command handler. + /// + /// to add or update. + /// Configuration has not yet been loaded. + internal void AddConnection(ProxyConnection connection) + { + if (m_currentConfiguration is null) + throw new InvalidOperationException("Configuration is not yet loaded."); + + lock (m_streamSplitters) + { + StreamProxy existing = m_streamSplitters.Find(s => s.ID == connection.ID); + + if (existing is not null) + { + existing.ProxyConnection = connection; + m_currentConfiguration[connection.ID] = connection; + } + else + { + StreamProxy splitter = new StreamProxy(connection); + + splitter.StatusMessage += splitter_StatusMessage; + splitter.ProcessException += splitter_ProcessException; + + m_streamSplitters.Add(splitter); + m_serviceHelper.ServiceComponents.Add(splitter); + m_currentConfiguration.Add(connection); + } + } + + BackupConfiguration(); + + ProxyConnectionCollection.SaveConfiguration( + m_currentConfiguration, + FilePath.GetAbsolutePath(ConfigurationFileName)); + } + #endregion #endregion diff --git a/Source/Applications/StreamSplitter/StreamSplitter.csproj b/Source/Applications/StreamSplitter/StreamSplitter.csproj index 56da099..7dd463c 100755 --- a/Source/Applications/StreamSplitter/StreamSplitter.csproj +++ b/Source/Applications/StreamSplitter/StreamSplitter.csproj @@ -171,7 +171,9 @@ + + diff --git a/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs b/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs index 8ac0a7d..c3cc4e6 100755 --- a/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs +++ b/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs @@ -18,6 +18,9 @@ // ---------------------------------------------------------------------------------------------------- // 09/03/2013 - J. Ritchie Carroll // Generated original version of source code. +// 06/05/2026 - Marcos Vinicius Snak +// ApplyStreamProxyStatusUpdates: detect connections added externally (e.g. via REST API) +// and trigger automatic DownloadConfig so the Manager list stays in sync. // //****************************************************************************************************** @@ -817,7 +820,7 @@ private void m_serviceConnection_StatusMessage(object sender, EventArgs 0) + if (m_serviceConnection is not null && m_proxyConnections is not null) m_serviceConnection.SendCommand("GetStreamProxyStatus"); } @@ -853,29 +856,44 @@ private void m_serviceConnection_ServiceResponse(object sender, EventArgs 0; + + if (!hasUnknownConnections) { - // Attempt to find associated proxy connection - ProxyConnection proxyConnection; - - lock (m_proxyConnections) - proxyConnection = m_proxyConnections.FirstOrDefault(connection => connection.ID == proxyStatus.ID); + // Apply updates for each stream proxy status + foreach (StreamProxyStatus proxyStatus in streamProxies) + { + // Attempt to find associated proxy connection + ProxyConnection proxyConnection; - if (proxyConnection is null) - continue; + lock (m_proxyConnections) + proxyConnection = m_proxyConnections.FirstOrDefault(connection => connection.ID == proxyStatus.ID); - proxyConnection.ConnectionState = proxyStatus.ConnectionState; + if (proxyConnection is null) + { + // Connection exists in the service but not locally — added externally (e.g. via REST API). + hasUnknownConnections = true; + continue; + } - if (proxyConnectionEditor.ID != proxyConnection.ID) - continue; + proxyConnection.ConnectionState = proxyStatus.ConnectionState; - BeginInvoke(ApplyStreamProxyStatusUpdate, proxyStatus); + if (proxyConnectionEditor.ID != proxyConnection.ID) + continue; + + BeginInvoke(ApplyStreamProxyStatusUpdate, proxyStatus); + } } + // When the service reports connections not present in the local list, + // download the full configuration to keep the Manager in sync. + if (hasUnknownConnections) + m_serviceConnection?.SendCommand("DownloadConfig"); + BeginInvoke(dataGridView.Refresh); } From 3bd0accbbc8ccf1affe599d8fb348a6337ce0871 Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Snak Date: Mon, 8 Jun 2026 09:13:46 -0300 Subject: [PATCH 06/12] Adds PATCH /api/connections/{id} endpoint and fixes Manager reload on local config creation --- .../Api/Controllers/ConnectionsController.cs | 121 ++++++++++++++++++ .../Api/Models/UpdateConnectionRequest.cs | 70 ++++++++++ .../StreamSplitter/ServiceHost.cs | 4 + .../StreamSplitter/StreamSplitter.csproj | 1 + .../StreamSplitterManager.cs | 47 +++---- 5 files changed, 220 insertions(+), 23 deletions(-) create mode 100644 Source/Applications/StreamSplitter/Api/Models/UpdateConnectionRequest.cs diff --git a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs index d634161..3f3a1a7 100644 --- a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs +++ b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs @@ -29,6 +29,7 @@ using System.Net.Http; using System.Threading.Tasks; using System.Web.Http; +using GSF; using GSF.Diagnostics; using StreamSplitter.Api.Models; @@ -333,6 +334,126 @@ public async Task ImportFromFile() return Created("/api/connections", created.ToArray()); } + /// + /// Partially updates a proxy connection. Only the fields present in the request body + /// are changed; omitted fields retain their current values. The connection is + /// automatically restarted after the update. + /// + /// Unique identifier of the connection to update. + /// Fields to update. All properties are optional. + /// + /// 200 OK with the updated , or 404 if not found. + /// + [HttpPatch, Route("{id:guid}")] + public IHttpActionResult UpdateConnection(Guid id, [FromBody] UpdateConnectionRequest request) + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "UpdateConnection", + $"PATCH /api/connections/{id} requested. CorrelationId={correlationId}"); + + if (request is null) + { + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "Request body is required." + }); + } + + ProxyConnectionCollection configuration = ServiceHost.Current?.CurrentConfiguration; + ProxyConnection existing = configuration?[id]; + + if (existing is null) + { + s_log.Publish( + MessageLevel.Info, + "UpdateConnection", + $"Connection {id} not found. CorrelationId={correlationId}"); + + return Content(HttpStatusCode.NotFound, new + { + status = 404, + title = "Not Found", + detail = $"Connection with ID '{id}' was not found." + }); + } + + // Build the updated connection string from the partial request. + string updatedConnectionString; + + if (!string.IsNullOrEmpty(request.ConnectionString)) + { + // Consumer provided a complete connection string — use it directly. + updatedConnectionString = request.ConnectionString; + } + else + { + // Consumer provided only sub-fields — merge into the existing connection string. + Dictionary settings = existing.ConnectionString.ParseKeyValuePairs(); + + if (request.Name != null) + settings["name"] = request.Name; + + if (request.Enabled.HasValue) + settings["enabled"] = request.Enabled.Value.ToString().ToLower(); + + if (request.SourceSettings != null) + { + if (string.IsNullOrEmpty(request.SourceSettings)) + settings.Remove("sourceSettings"); + else + settings["sourceSettings"] = request.SourceSettings; + } + + if (request.ProxySettings != null) + { + if (string.IsNullOrEmpty(request.ProxySettings)) + settings.Remove("proxySettings"); + else + settings["proxySettings"] = request.ProxySettings; + } + + updatedConnectionString = settings.JoinKeyValuePairs(); + } + + ProxyConnection updated = new ProxyConnection + { + ConnectionString = updatedConnectionString, + ConnectionParameters = existing.ConnectionParameters + }; + + updated.ID = existing.ID; + + try + { + ServiceHost.Current.AddConnection(updated); + } + catch (InvalidOperationException ex) + { + return Content(HttpStatusCode.ServiceUnavailable, new + { + status = 503, + title = "Service Unavailable", + detail = ex.Message + }); + } + + ConnectionDto dto = ConnectionDto.FromProxyConnection( + updated, + ServiceHost.Current.GetRuntimeConnectionState(id)); + + s_log.Publish( + MessageLevel.Info, + "UpdateConnection", + $"Connection '{updated.Name}' updated. Id={id}. CorrelationId={correlationId}"); + + return Ok(dto); + } + // Extracts the X-Correlation-Id header value, or generates a new GUID string if absent. private string GetCorrelationId() { diff --git a/Source/Applications/StreamSplitter/Api/Models/UpdateConnectionRequest.cs b/Source/Applications/StreamSplitter/Api/Models/UpdateConnectionRequest.cs new file mode 100644 index 0000000..6be46d8 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Models/UpdateConnectionRequest.cs @@ -0,0 +1,70 @@ +//****************************************************************************************************** +// UpdateConnectionRequest.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/05/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// +//****************************************************************************************************** + +namespace StreamSplitter.Api.Models +{ + /// + /// Request body for partially updating an existing proxy connection via the REST API. + /// All properties are optional — only the fields that are present (non-null) are applied; + /// omitted fields retain their current values. + /// + public class UpdateConnectionRequest + { + #region [ Properties ] + + /// + /// Gets or sets the full GSF connection string (key=value pairs). + /// When provided and non-empty, it replaces the connection string entirely and all + /// sub-fields (, , , + /// ) are re-parsed from it. + /// When null or empty, the individual sub-field properties below are applied instead. + /// + public string ConnectionString { get; set; } + + /// + /// Gets or sets the display name of the connection. + /// Null keeps the existing value. + /// + public string Name { get; set; } + + /// + /// Gets or sets a value indicating whether the connection should be enabled. + /// Null keeps the existing value. + /// + public bool? Enabled { get; set; } + + /// + /// Gets or sets the source-side settings sub-string. + /// Null keeps the existing value; empty string removes the sub-string entirely. + /// + public string SourceSettings { get; set; } + + /// + /// Gets or sets the proxy-side settings sub-string. + /// Null keeps the existing value; empty string removes the sub-string entirely. + /// + public string ProxySettings { get; set; } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/ServiceHost.cs b/Source/Applications/StreamSplitter/ServiceHost.cs index b19b0bf..cf18097 100755 --- a/Source/Applications/StreamSplitter/ServiceHost.cs +++ b/Source/Applications/StreamSplitter/ServiceHost.cs @@ -69,6 +69,7 @@ public sealed partial class ServiceHost : ServiceBase private const int DefaultMaxLogFiles = 300; private const bool DefaultWebHostingEnabled = true; private const string DefaultWebHostURL = "http://localhost:8283"; + private const string ApiConfigChangedBroadcast = "[API_CONFIG_CHANGED]"; // Fields private AutoResetEvent m_configurationLoadComplete; @@ -1316,6 +1317,9 @@ internal void AddConnection(ProxyConnection connection) ProxyConnectionCollection.SaveConfiguration( m_currentConfiguration, FilePath.GetAbsolutePath(ConfigurationFileName)); + + // Notify connected Manager instances to refresh their configuration view. + DisplayStatusMessage(ApiConfigChangedBroadcast, UpdateType.Information); } #endregion diff --git a/Source/Applications/StreamSplitter/StreamSplitter.csproj b/Source/Applications/StreamSplitter/StreamSplitter.csproj index 7dd463c..07e73a6 100755 --- a/Source/Applications/StreamSplitter/StreamSplitter.csproj +++ b/Source/Applications/StreamSplitter/StreamSplitter.csproj @@ -172,6 +172,7 @@ + diff --git a/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs b/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs index c3cc4e6..e1e0400 100755 --- a/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs +++ b/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs @@ -75,6 +75,7 @@ public partial class StreamSplitterManager : Form // Constants private const int MaximumToolTipSize = 1500; + private const string ApiConfigChangedMarker = "[API_CONFIG_CHANGED]"; // Fields private string m_configurationFileName; @@ -816,11 +817,15 @@ private void m_serviceConnection_StatusMessage(object sender, EventArgs toolStripStatusLabelStatus.Text = e.Argument2.Replace(Environment.NewLine, " "))); UpdateToolTip(e.Argument2); + + // Auto-refresh configuration when the REST API signals a change. + if (e.Argument2.ToNonNullString().Contains(ApiConfigChangedMarker)) + m_serviceConnection?.SendCommand("DownloadConfig"); } private void m_refreshProxyStatusTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) { - if (m_serviceConnection is not null && m_proxyConnections is not null) + if (m_serviceConnection is not null && m_proxyConnections is not null && m_proxyConnections.Count > 0) m_serviceConnection.SendCommand("GetStreamProxyStatus"); } @@ -856,37 +861,33 @@ private void m_serviceConnection_ServiceResponse(object sender, EventArgs 0; + bool hasUnknownConnections = false; - if (!hasUnknownConnections) + // Apply updates for each stream proxy status + foreach (StreamProxyStatus proxyStatus in streamProxies) { - // Apply updates for each stream proxy status - foreach (StreamProxyStatus proxyStatus in streamProxies) - { - // Attempt to find associated proxy connection - ProxyConnection proxyConnection; + // Attempt to find associated proxy connection + ProxyConnection proxyConnection; - lock (m_proxyConnections) - proxyConnection = m_proxyConnections.FirstOrDefault(connection => connection.ID == proxyStatus.ID); + lock (m_proxyConnections) + proxyConnection = m_proxyConnections.FirstOrDefault(connection => connection.ID == proxyStatus.ID); - if (proxyConnection is null) - { - // Connection exists in the service but not locally — added externally (e.g. via REST API). - hasUnknownConnections = true; - continue; - } + if (proxyConnection is null) + { + // Connection exists in the service but not locally — added externally (e.g. via REST API). + hasUnknownConnections = true; + continue; + } - proxyConnection.ConnectionState = proxyStatus.ConnectionState; + proxyConnection.ConnectionState = proxyStatus.ConnectionState; - if (proxyConnectionEditor.ID != proxyConnection.ID) - continue; + if (proxyConnectionEditor.ID != proxyConnection.ID) + continue; - BeginInvoke(ApplyStreamProxyStatusUpdate, proxyStatus); - } + BeginInvoke(ApplyStreamProxyStatusUpdate, proxyStatus); } // When the service reports connections not present in the local list, From 0a1aa576f45bdc44f594f53a7f923df90aef82ab Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Snak Date: Mon, 8 Jun 2026 09:35:09 -0300 Subject: [PATCH 07/12] Adds XML documentation and response schemas to Swagger UI endpoints --- .../Api/Controllers/ConnectionsController.cs | 48 +++++++++++++++++++ .../StreamSplitter/Api/Startup.cs | 11 +++++ .../StreamSplitter/StreamSplitter.csproj | 5 ++ 3 files changed, 64 insertions(+) diff --git a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs index 3f3a1a7..a5e4e48 100644 --- a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs +++ b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs @@ -29,6 +29,7 @@ using System.Net.Http; using System.Threading.Tasks; using System.Web.Http; +using System.Web.Http.Description; using GSF; using GSF.Diagnostics; using StreamSplitter.Api.Models; @@ -55,7 +56,9 @@ public class ConnectionsController : ApiController /// Returns all currently configured proxy connections. /// /// Array of objects representing all configured connections. + /// Array of connections. Empty array when no connections are configured. [HttpGet, Route("")] + [ResponseType(typeof(ConnectionDto[]))] public IHttpActionResult GetConnections() { string correlationId = GetCorrelationId(); @@ -96,7 +99,10 @@ public IHttpActionResult GetConnections() /// /// when found; 404 with a problem detail body when not found. /// + /// The requested connection with its current runtime state. + /// No connection found with the given ID. [HttpGet, Route("{id:guid}")] + [ResponseType(typeof(ConnectionDto))] public IHttpActionResult GetConnectionById(Guid id) { string correlationId = GetCorrelationId(); @@ -147,7 +153,22 @@ public IHttpActionResult GetConnectionById(Guid id) /// 201 Created with the array of created objects, /// or 400 Bad Request if any item fails validation. /// + /// + /// The connectionString uses the GSF key=value pair format, with nested + /// sourceSettings and proxySettings sub-strings. Example: + /// + /// [ + /// { + /// "connectionString": "name=PDC-001;enabled=true;sourceSettings={server=192.168.1.1;port=4712;phasorProtocol=IEEEC37_118V2;accessID=1};proxySettings={port=4713}" + /// } + /// ] + /// + /// + /// Array of created connections, each with a server-generated id. + /// One or more items are missing a required connectionString. + /// Service configuration not yet loaded. [HttpPost, Route("")] + [ResponseType(typeof(ConnectionDto[]))] public IHttpActionResult CreateConnections([FromBody] CreateConnectionRequest[] requests) { string correlationId = GetCorrelationId(); @@ -228,7 +249,15 @@ public IHttpActionResult CreateConnections([FromBody] CreateConnectionRequest[] /// 201 Created with the array of imported objects, /// or 400 Bad Request if the file is missing, empty, or not a valid .s3config. /// + /// + /// Upload a .s3config file exported from StreamSplitterManager using + /// File → Save Configuration. The file is a SOAP-serialized ProxyConnectionCollection. + /// All connections in the file are created with new server-generated IDs. + /// + /// Array of imported connections, each with a new server-generated id. + /// File missing, empty, or not a valid .s3config. [HttpPost, Route("import")] + [ResponseType(typeof(ConnectionDto[]))] public async Task ImportFromFile() { string correlationId = GetCorrelationId(); @@ -344,7 +373,26 @@ public async Task ImportFromFile() /// /// 200 OK with the updated , or 404 if not found. /// + /// + /// Send only the fields that need to change; all others are preserved. + /// Disable a connection: + /// { "enabled": false } + /// Rename a connection: + /// { "name": "PDC-001-updated" } + /// Replace the full connection string (all sub-fields re-parsed): + /// { "connectionString": "name=PDC-001;enabled=true;sourceSettings={...};proxySettings={...}" } + /// + /// If connectionString is provided, it takes precedence and the individual + /// name, enabled, sourceSettings, and proxySettings fields + /// are ignored. + /// + /// + /// Updated connection with its new runtime state. + /// Request body is missing. + /// No connection found with the given ID. + /// Service configuration not yet loaded. [HttpPatch, Route("{id:guid}")] + [ResponseType(typeof(ConnectionDto))] public IHttpActionResult UpdateConnection(Guid id, [FromBody] UpdateConnectionRequest request) { string correlationId = GetCorrelationId(); diff --git a/Source/Applications/StreamSplitter/Api/Startup.cs b/Source/Applications/StreamSplitter/Api/Startup.cs index 115bcc0..4628088 100644 --- a/Source/Applications/StreamSplitter/Api/Startup.cs +++ b/Source/Applications/StreamSplitter/Api/Startup.cs @@ -20,9 +20,12 @@ // Generated original version of source code. // 06/05/2026 - Marcos Vinicius Snak // Added FileUploadOperationFilter to enable file picker on /import endpoint in Swagger UI. +// Added XML documentation comments to Swagger via IncludeXmlComments. // //****************************************************************************************************** +using System; +using System.IO; using System.Web.Http; using Newtonsoft.Json.Converters; using Owin; @@ -69,6 +72,14 @@ public void Configuration(IAppBuilder app) c.SingleApiVersion("v1", "Stream Splitter API"); c.DescribeAllEnumsAsStrings(); c.OperationFilter(); + + // Load XML documentation generated by the C# compiler so that + // controller summaries, parameter descriptions, and remarks with + // examples are displayed in the Swagger UI. + string xmlPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "StreamSplitter.xml"); + + if (File.Exists(xmlPath)) + c.IncludeXmlComments(xmlPath); }) .EnableSwaggerUi(); diff --git a/Source/Applications/StreamSplitter/StreamSplitter.csproj b/Source/Applications/StreamSplitter/StreamSplitter.csproj index 07e73a6..93847a4 100755 --- a/Source/Applications/StreamSplitter/StreamSplitter.csproj +++ b/Source/Applications/StreamSplitter/StreamSplitter.csproj @@ -53,6 +53,8 @@ 4 AllRules.ruleset false + ..\..\..\Build\Output\Debug\Applications\StreamSplitter\StreamSplitter.xml + 1591 pdbonly @@ -63,6 +65,8 @@ 4 AllRules.ruleset false + ..\..\..\Build\Output\Release\Applications\StreamSplitter\StreamSplitter.xml + 1591 @@ -105,6 +109,7 @@ ..\..\packages\Swashbuckle.Core.5.6.0\lib\net40\Swashbuckle.Core.dll + ..\..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll From d6711481f829d82fec77310ef9be79f17a84bc69 Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Snak Date: Mon, 8 Jun 2026 18:15:40 -0300 Subject: [PATCH 08/12] Fixes Manager restoring deleted connections after API auto-refresh changes --- .../StreamSplitterManager/StreamSplitterManager.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs b/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs index e1e0400..bb8b6c7 100755 --- a/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs +++ b/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs @@ -864,8 +864,6 @@ private void ApplyStreamProxyStatusUpdates(object state) if (state is not StreamProxyStatus[] streamProxies || m_proxyConnections is null || m_proxyConnections.Count == 0) return; - bool hasUnknownConnections = false; - // Apply updates for each stream proxy status foreach (StreamProxyStatus proxyStatus in streamProxies) { @@ -876,11 +874,7 @@ private void ApplyStreamProxyStatusUpdates(object state) proxyConnection = m_proxyConnections.FirstOrDefault(connection => connection.ID == proxyStatus.ID); if (proxyConnection is null) - { - // Connection exists in the service but not locally — added externally (e.g. via REST API). - hasUnknownConnections = true; continue; - } proxyConnection.ConnectionState = proxyStatus.ConnectionState; @@ -890,11 +884,6 @@ private void ApplyStreamProxyStatusUpdates(object state) BeginInvoke(ApplyStreamProxyStatusUpdate, proxyStatus); } - // When the service reports connections not present in the local list, - // download the full configuration to keep the Manager in sync. - if (hasUnknownConnections) - m_serviceConnection?.SendCommand("DownloadConfig"); - BeginInvoke(dataGridView.Refresh); } From a5936048a53cc6746103f729c22e9542db1f6160 Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Snak Date: Mon, 8 Jun 2026 18:52:34 -0300 Subject: [PATCH 09/12] Adds DELETE /api/connections/{id} endpoint with StreamProxy shutdown and backup --- .../Api/Controllers/ConnectionsController.cs | 59 +++++++++++++++++++ .../StreamSplitter/ServiceHost.cs | 49 +++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs index a5e4e48..f4ff7cd 100644 --- a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs +++ b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs @@ -502,6 +502,65 @@ public IHttpActionResult UpdateConnection(Guid id, [FromBody] UpdateConnectionRe return Ok(dto); } + /// + /// Removes the proxy connection identified by , stops its data + /// flow, and persists the updated configuration. + /// + /// Unique identifier of the connection to remove. + /// 204 No Content on success; 404 if not found. + /// Connection removed and data flow stopped. + /// No connection found with the given ID. + /// Service configuration not yet loaded. + [HttpDelete, Route("{id:guid}")] + [ResponseType(typeof(void))] + public IHttpActionResult DeleteConnection(Guid id) + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "DeleteConnection", + $"DELETE /api/connections/{id} requested. CorrelationId={correlationId}"); + + bool removed; + + try + { + removed = ServiceHost.Current.RemoveConnection(id); + } + catch (InvalidOperationException ex) + { + return Content(HttpStatusCode.ServiceUnavailable, new + { + status = 503, + title = "Service Unavailable", + detail = ex.Message + }); + } + + if (!removed) + { + s_log.Publish( + MessageLevel.Info, + "DeleteConnection", + $"Connection {id} not found. CorrelationId={correlationId}"); + + return Content(HttpStatusCode.NotFound, new + { + status = 404, + title = "Not Found", + detail = $"Connection with ID '{id}' was not found." + }); + } + + s_log.Publish( + MessageLevel.Info, + "DeleteConnection", + $"Connection {id} removed. CorrelationId={correlationId}"); + + return StatusCode(HttpStatusCode.NoContent); + } + // Extracts the X-Correlation-Id header value, or generates a new GUID string if absent. private string GetCorrelationId() { diff --git a/Source/Applications/StreamSplitter/ServiceHost.cs b/Source/Applications/StreamSplitter/ServiceHost.cs index cf18097..c1e5505 100755 --- a/Source/Applications/StreamSplitter/ServiceHost.cs +++ b/Source/Applications/StreamSplitter/ServiceHost.cs @@ -1322,6 +1322,55 @@ internal void AddConnection(ProxyConnection connection) DisplayStatusMessage(ApiConfigChangedBroadcast, UpdateType.Information); } + /// + /// Removes the identified by from the + /// running configuration, stops the associated , and persists the change. + /// + /// ID of the to remove. + /// + /// true if the connection was found and removed; false if it did not exist. + /// + /// Configuration is not yet loaded. + internal bool RemoveConnection(Guid id) + { + if (m_currentConfiguration is null) + throw new InvalidOperationException("Configuration is not yet loaded."); + + lock (m_streamSplitters) + { + ProxyConnection connection = m_currentConfiguration[id]; + + if (connection is null) + return false; + + StreamProxy splitter = m_streamSplitters.Find(s => s.ID == id); + + if (splitter is not null) + { + splitter.Stop(); + splitter.Dispose(); + m_streamSplitters.Remove(splitter); + m_serviceHelper.ServiceComponents.Remove(splitter); + } + + // RemovingItem fires but has no subscriber in ServiceHost — no UI dialog. + // In StreamSplitterManager the event shows a confirmation dialog, but that + // code runs in a different process and does not affect the service. + m_currentConfiguration.Remove(connection); + } + + BackupConfiguration(); + + ProxyConnectionCollection.SaveConfiguration( + m_currentConfiguration, + FilePath.GetAbsolutePath(ConfigurationFileName)); + + // Notify connected Manager instances to refresh their configuration view. + DisplayStatusMessage(ApiConfigChangedBroadcast, UpdateType.Information); + + return true; + } + #endregion #endregion From 470f4d26e7fd27469757407e3808b851194fe4c6 Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Snak Date: Mon, 8 Jun 2026 19:04:17 -0300 Subject: [PATCH 10/12] Adds GET /api/connections/{id}/status endpoint with operational metrics --- .../Api/Controllers/ConnectionsController.cs | 41 ++++++ .../Api/Models/ConnectionStatusDto.cs | 121 ++++++++++++++++++ .../StreamSplitter/ServiceHost.cs | 19 +++ .../StreamSplitter/StreamSplitter.csproj | 1 + 4 files changed, 182 insertions(+) create mode 100644 Source/Applications/StreamSplitter/Api/Models/ConnectionStatusDto.cs diff --git a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs index f4ff7cd..767bcad 100644 --- a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs +++ b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs @@ -502,6 +502,47 @@ public IHttpActionResult UpdateConnection(Guid id, [FromBody] UpdateConnectionRe return Ok(dto); } + /// + /// Returns the current operational status of the proxy connection identified by + /// without retrieving its full configuration. + /// + /// Unique identifier of the connection. + /// + /// 200 OK with ; 404 if not found. + /// + /// Current operational status including state, metrics, and recent messages. + /// No connection found with the given ID. + [HttpGet, Route("{id:guid}/status")] + [ResponseType(typeof(ConnectionStatusDto))] + public IHttpActionResult GetConnectionStatus(Guid id) + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "GetConnectionStatus", + $"GET /api/connections/{id}/status requested. CorrelationId={correlationId}"); + + ConnectionStatusDto status = ServiceHost.Current?.GetConnectionStatus(id); + + if (status is null) + { + s_log.Publish( + MessageLevel.Info, + "GetConnectionStatus", + $"Connection {id} not found. CorrelationId={correlationId}"); + + return Content(HttpStatusCode.NotFound, new + { + status = 404, + title = "Not Found", + detail = $"Connection with ID '{id}' was not found." + }); + } + + return Ok(status); + } + /// /// Removes the proxy connection identified by , stops its data /// flow, and persists the updated configuration. diff --git a/Source/Applications/StreamSplitter/Api/Models/ConnectionStatusDto.cs b/Source/Applications/StreamSplitter/Api/Models/ConnectionStatusDto.cs new file mode 100644 index 0000000..a1035b0 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Models/ConnectionStatusDto.cs @@ -0,0 +1,121 @@ +//****************************************************************************************************** +// ConnectionStatusDto.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/08/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// +//****************************************************************************************************** + +using System; +using GSF; + +namespace StreamSplitter.Api.Models +{ + /// + /// Lightweight data transfer object representing the current operational status of a + /// and its associated . + /// Returned by GET /api/connections/{id}/status. + /// + public sealed class ConnectionStatusDto + { + #region [ Properties ] + + /// Gets the unique identifier of the connection. + public Guid Id { get; private set; } + + /// Gets the display name of the connection. + public string Name { get; private set; } + + /// Gets a value indicating whether the connection is enabled in configuration. + public bool Enabled { get; private set; } + + /// Gets the current runtime of the connection. + public ConnectionState ConnectionState { get; private set; } + + /// Gets a human-readable description of the current connection state. + public string ConnectionStateDescription { get; private set; } + + /// + /// Gets the source connection endpoint in the format host:port/accessID. + /// null when no live exists (e.g. connection is disabled). + /// + public string ConnectionInfo { get; private set; } + + /// + /// Gets the total number of bytes sent to downstream clients since the proxy started. + /// Zero when no live exists. + /// + public long TotalBytesSent { get; private set; } + + /// + /// Gets the total active run time of the proxy in seconds. + /// Zero when no live exists. + /// + public double RunTime { get; private set; } + + /// + /// Gets the most recent status messages produced by the proxy (up to 2 048 characters). + /// null when no live exists. + /// + public string RecentStatusMessages { get; private set; } + + #endregion + + #region [ Static ] + + // Static Methods + + /// + /// Creates a from the provided service-layer objects. + /// + /// Connection identifier. + /// + /// that holds the configuration. Must not be null. + /// + /// + /// Live associated with , + /// or null when the connection is disabled or not yet materialized. + /// + /// A populated . + /// + /// is null. + /// + public static ConnectionStatusDto FromServiceHost(Guid id, ProxyConnection connection, StreamProxy splitter) + { + if (connection is null) + throw new ArgumentNullException(nameof(connection)); + + ConnectionState state = splitter?.StreamProxyStatus.ConnectionState ?? ConnectionState.Disabled; + + return new ConnectionStatusDto + { + Id = id, + Name = connection.Name, + Enabled = connection.Enabled, + ConnectionState = state, + ConnectionStateDescription = state.GetDescription(), + ConnectionInfo = splitter?.ConnectionInfo, + TotalBytesSent = splitter?.TotalBytesSent ?? 0L, + RunTime = (double)(splitter?.RunTime ?? 0.0D), + RecentStatusMessages = splitter?.StreamProxyStatus.RecentStatusMessages + }; + } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/ServiceHost.cs b/Source/Applications/StreamSplitter/ServiceHost.cs index c1e5505..3dba359 100755 --- a/Source/Applications/StreamSplitter/ServiceHost.cs +++ b/Source/Applications/StreamSplitter/ServiceHost.cs @@ -138,6 +138,25 @@ public ServiceHost(IContainer container) /// internal ProxyConnectionCollection CurrentConfiguration => m_currentConfiguration; + /// + /// Returns an operational status snapshot for the connection identified by . + /// Returns null when the connection does not exist in the current configuration. + /// + /// ID of the to look up. + internal Api.Models.ConnectionStatusDto GetConnectionStatus(Guid id) + { + ProxyConnection connection = m_currentConfiguration?[id]; + + if (connection is null) + return null; + + lock (m_streamSplitters) + { + StreamProxy splitter = m_streamSplitters.Find(s => s.ID == id); + return Api.Models.ConnectionStatusDto.FromServiceHost(id, connection, splitter); + } + } + /// /// Gets the runtime for the identified by /// . Returns when no live diff --git a/Source/Applications/StreamSplitter/StreamSplitter.csproj b/Source/Applications/StreamSplitter/StreamSplitter.csproj index 93847a4..76e45da 100755 --- a/Source/Applications/StreamSplitter/StreamSplitter.csproj +++ b/Source/Applications/StreamSplitter/StreamSplitter.csproj @@ -177,6 +177,7 @@ + From 61fd84829b7839ace4d9ac5ecfd68e99d49968bc Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Snak Date: Tue, 9 Jun 2026 14:50:33 -0300 Subject: [PATCH 11/12] Simplifies PATCH /api/connections/{id} to sub-fields only removing connectionString override --- .../Api/Controllers/ConnectionsController.cs | 63 ++++++++----------- .../Api/Models/UpdateConnectionRequest.cs | 12 +--- 2 files changed, 29 insertions(+), 46 deletions(-) diff --git a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs index 767bcad..7b2d201 100644 --- a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs +++ b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs @@ -379,12 +379,11 @@ public async Task ImportFromFile() /// { "enabled": false } /// Rename a connection: /// { "name": "PDC-001-updated" } - /// Replace the full connection string (all sub-fields re-parsed): - /// { "connectionString": "name=PDC-001;enabled=true;sourceSettings={...};proxySettings={...}" } + /// Update the source settings: + /// { "sourceSettings": "server=192.168.1.2;port=4712;phasorProtocol=IEEEC37_118V2;accessID=1" } /// - /// If connectionString is provided, it takes precedence and the individual - /// name, enabled, sourceSettings, and proxySettings fields - /// are ignored. + /// Each field is independent — you can combine any of them in the same request. + /// For sourceSettings and proxySettings, sending an empty string removes the sub-string. /// /// /// Updated connection with its new runtime state. @@ -430,44 +429,34 @@ public IHttpActionResult UpdateConnection(Guid id, [FromBody] UpdateConnectionRe }); } - // Build the updated connection string from the partial request. - string updatedConnectionString; + // Merge only the provided sub-fields into the existing connection string. + // Omitted fields (null) retain their current values. + Dictionary settings = existing.ConnectionString.ParseKeyValuePairs(); - if (!string.IsNullOrEmpty(request.ConnectionString)) - { - // Consumer provided a complete connection string — use it directly. - updatedConnectionString = request.ConnectionString; - } - else - { - // Consumer provided only sub-fields — merge into the existing connection string. - Dictionary settings = existing.ConnectionString.ParseKeyValuePairs(); - - if (request.Name != null) - settings["name"] = request.Name; + if (request.Name != null) + settings["name"] = request.Name; - if (request.Enabled.HasValue) - settings["enabled"] = request.Enabled.Value.ToString().ToLower(); - - if (request.SourceSettings != null) - { - if (string.IsNullOrEmpty(request.SourceSettings)) - settings.Remove("sourceSettings"); - else - settings["sourceSettings"] = request.SourceSettings; - } + if (request.Enabled.HasValue) + settings["enabled"] = request.Enabled.Value.ToString().ToLower(); - if (request.ProxySettings != null) - { - if (string.IsNullOrEmpty(request.ProxySettings)) - settings.Remove("proxySettings"); - else - settings["proxySettings"] = request.ProxySettings; - } + if (request.SourceSettings != null) + { + if (string.IsNullOrEmpty(request.SourceSettings)) + settings.Remove("sourceSettings"); + else + settings["sourceSettings"] = request.SourceSettings; + } - updatedConnectionString = settings.JoinKeyValuePairs(); + if (request.ProxySettings != null) + { + if (string.IsNullOrEmpty(request.ProxySettings)) + settings.Remove("proxySettings"); + else + settings["proxySettings"] = request.ProxySettings; } + string updatedConnectionString = settings.JoinKeyValuePairs(); + ProxyConnection updated = new ProxyConnection { ConnectionString = updatedConnectionString, diff --git a/Source/Applications/StreamSplitter/Api/Models/UpdateConnectionRequest.cs b/Source/Applications/StreamSplitter/Api/Models/UpdateConnectionRequest.cs index 6be46d8..3dcf6de 100644 --- a/Source/Applications/StreamSplitter/Api/Models/UpdateConnectionRequest.cs +++ b/Source/Applications/StreamSplitter/Api/Models/UpdateConnectionRequest.cs @@ -18,6 +18,9 @@ // ---------------------------------------------------------------------------------------------------- // 06/05/2026 - Marcos Vinicius Snak // Generated original version of source code. +// 06/08/2026 - Marcos Vinicius Snak +// Removed ConnectionString field; PATCH now uses individual sub-fields only for +// true partial-update semantics without conflicting override behaviour. // //****************************************************************************************************** @@ -32,15 +35,6 @@ public class UpdateConnectionRequest { #region [ Properties ] - /// - /// Gets or sets the full GSF connection string (key=value pairs). - /// When provided and non-empty, it replaces the connection string entirely and all - /// sub-fields (, , , - /// ) are re-parsed from it. - /// When null or empty, the individual sub-field properties below are applied instead. - /// - public string ConnectionString { get; set; } - /// /// Gets or sets the display name of the connection. /// Null keeps the existing value. From b4ab529a3222584e4dd95fbae4c6db9d2ab44973 Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Snak Date: Fri, 12 Jun 2026 17:01:38 -0300 Subject: [PATCH 12/12] Fix WiX targets resolution for VS 2025 (MSBuild v18) --- .../StreamSplitterSetup/StreamSplitterSetup.wixproj | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wixproj b/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wixproj index 790feec..21af24c 100755 --- a/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wixproj +++ b/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wixproj @@ -8,8 +8,9 @@ 2.0 StreamSplitterSetup Package - $(MSBuildExtensionsPath32)\Microsoft\WiX\v3.x\Wix.targets - $(MSBuildExtensionsPath)\Microsoft\WiX\v3.x\Wix.targets + $(MSBuildExtensionsPath32)\Microsoft\WiX\v3.x\Wix.targets + $(MSBuildExtensionsPath)\Microsoft\WiX\v3.x\Wix.targets + C:\Program Files (x86)\MSBuild\Microsoft\WiX\v3.x\Wix.targets SAK SAK SAK